Express.js Routing and Request Handling

In the previous post, you mastered middleware — the backbone of every Express application. Now it's time to tackle routing, the part that determines what happens when a request hits your server.
If your app has more than a handful of endpoints, dumping everything into a single app.js becomes unmanageable fast. Express Router lets you split routes into modules, nest them, and keep your codebase clean as it grows.
What You'll Learn
Learning Outcomes:
✅ Organize routes into modules with Express Router
✅ Build nested and prefixed route structures
✅ Use route parameters, query strings, and request body correctly
✅ Validate and sanitize input with express-validator
✅ Handle file uploads with multer
✅ Design RESTful APIs that follow industry conventions
✅ Return proper HTTP status codes for every response
1. Express Router Basics
Until now, you've been defining routes directly on app:
app.get('/api/todos', (req, res) => { ... });
app.post('/api/todos', (req, res) => { ... });This works for small apps. But when you have users, todos, products, orders, comments, and admin routes — all in one file — it becomes a nightmare. Express Router solves this.
Creating a Router
A Router is a mini Express application that handles its own routes:
// routes/todos.js
const express = require('express');
const router = express.Router();
// These paths are relative to wherever the router is mounted
router.get('/', (req, res) => {
res.json({ success: true, data: todos });
});
router.post('/', (req, res) => {
const todo = { id: Date.now(), title: req.body.title, completed: false };
todos.push(todo);
res.status(201).json({ success: true, data: todo });
});
router.get('/:id', (req, res) => {
const todo = todos.find((t) => t.id === Number(req.params.id));
if (!todo) {
return res.status(404).json({ success: false, message: 'Todo not found' });
}
res.json({ success: true, data: todo });
});
module.exports = router;Mounting the Router
In your main app.js, mount the router at a path prefix:
// app.js
const express = require('express');
const todoRoutes = require('./routes/todos');
const userRoutes = require('./routes/users');
const app = express();
app.use(express.json());
// Mount routers at prefixes
app.use('/api/todos', todoRoutes);
app.use('/api/users', userRoutes);
app.listen(3000, () => {
console.log('Server running on port 3000');
});Now router.get('/') inside routes/todos.js maps to GET /api/todos, and router.get('/:id') maps to GET /api/todos/:id. Clean and obvious.
Project Structure
Here's how a well-organized Express project looks:
src/
├── app.js # Express app setup
├── server.js # Entry point (starts the server)
├── routes/
│ ├── index.js # Combines all routers
│ ├── todos.js # Todo routes
│ ├── users.js # User routes
│ └── admin.js # Admin routes
├── controllers/
│ ├── todoController.js # Todo business logic
│ └── userController.js # User business logic
├── middleware/
│ ├── auth.js # Authentication middleware
│ ├── validate.js # Validation middleware
│ └── errorHandler.js # Error handling middleware
└── utils/
└── errors.js # Custom error classes2. Route Parameters and Query Strings
Express gives you three main ways to receive data from the client: route parameters, query strings, and the request body.
Route Parameters
Route parameters are named segments in the URL path. They're used for identifying specific resources:
// Single parameter
router.get('/users/:id', (req, res) => {
console.log(req.params.id); // "42"
// GET /users/42
});
// Multiple parameters
router.get('/users/:userId/posts/:postId', (req, res) => {
console.log(req.params.userId); // "42"
console.log(req.params.postId); // "7"
// GET /users/42/posts/7
});Optional Parameters
Use a ? suffix to make a parameter optional:
// :format is optional
router.get('/reports/:year/:format?', (req, res) => {
const { year, format } = req.params;
// GET /reports/2025 → year: "2025", format: undefined
// GET /reports/2025/pdf → year: "2025", format: "pdf"
});Query Strings
Query strings are key-value pairs after the ? in a URL. They're used for filtering, sorting, and pagination:
router.get('/todos', (req, res) => {
const { page, limit, sort, completed } = req.query;
// GET /todos?page=2&limit=10&sort=date&completed=true
console.log(page); // "2" (always a string)
console.log(limit); // "10"
console.log(sort); // "date"
console.log(completed); // "true" (string, not boolean!)
});Important: Query parameters are always strings. You need to convert them:
router.get('/todos', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 10, 100); // Cap at 100
const completed = req.query.completed === 'true';
const start = (page - 1) * limit;
const paginatedTodos = todos
.filter((t) => (completed !== undefined ? t.completed === completed : true))
.slice(start, start + limit);
res.json({
success: true,
data: paginatedTodos,
pagination: {
page,
limit,
total: todos.length,
},
});
});Request Body
The request body carries data for POST, PUT, and PATCH requests:
// Make sure express.json() middleware is applied
app.use(express.json());
router.post('/todos', (req, res) => {
const { title, description, priority } = req.body;
// Body is already parsed as a JavaScript object
});When to Use What
| Data Source | Use Case | Example |
|---|---|---|
req.params | Identify a specific resource | GET /users/42 |
req.query | Filter, sort, paginate | GET /users?role=admin&page=2 |
req.body | Submit data (create/update) | POST /users with JSON body |
req.headers | Auth tokens, content type | Authorization: Bearer ... |
3. Advanced Routing Patterns
Route Chaining
When multiple HTTP methods share the same path, chain them with route():
router
.route('/:id')
.get((req, res) => {
// GET /todos/:id
const todo = findTodo(req.params.id);
res.json({ success: true, data: todo });
})
.put((req, res) => {
// PUT /todos/:id
const todo = updateTodo(req.params.id, req.body);
res.json({ success: true, data: todo });
})
.delete((req, res) => {
// DELETE /todos/:id
deleteTodo(req.params.id);
res.status(204).send();
});Router-Level Middleware
Apply middleware to specific routes or all routes within a router:
const express = require('express');
const router = express.Router();
const { authenticate } = require('../middleware/auth');
// Apply to ALL routes in this router
router.use(authenticate);
// Or apply to specific routes
router.get('/public', (req, res) => {
// This still requires auth because of router.use() above
});
// Better: separate public and protected routers
const publicRouter = express.Router();
const protectedRouter = express.Router();
protectedRouter.use(authenticate);
publicRouter.get('/health', (req, res) => res.json({ status: 'ok' }));
protectedRouter.get('/profile', (req, res) => res.json({ user: req.user }));Nested Routers
Routers can be nested inside other routers for complex URL structures:
// routes/users.js
const express = require('express');
const router = express.Router();
const postRouter = require('./userPosts');
// Nest post routes under users
// GET /api/users/:userId/posts → handled by postRouter
router.use('/:userId/posts', postRouter);
router.get('/', (req, res) => {
res.json({ success: true, data: users });
});
router.get('/:id', (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
res.json({ success: true, data: user });
});
module.exports = router;// routes/userPosts.js
const express = require('express');
// mergeParams: true gives us access to parent route params
const router = express.Router({ mergeParams: true });
router.get('/', (req, res) => {
const { userId } = req.params; // From parent router
const userPosts = posts.filter((p) => p.userId === Number(userId));
res.json({ success: true, data: userPosts });
});
router.post('/', (req, res) => {
const { userId } = req.params;
const post = {
id: Date.now(),
userId: Number(userId),
title: req.body.title,
content: req.body.content,
};
posts.push(post);
res.status(201).json({ success: true, data: post });
});
module.exports = router;Key detail: mergeParams: true — without this, req.params.userId is undefined inside the nested router.
Route Index File
Combine all routers into a single entry point:
// routes/index.js
const express = require('express');
const router = express.Router();
const todoRoutes = require('./todos');
const userRoutes = require('./users');
const adminRoutes = require('./admin');
const { authenticate } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roles');
// Public routes
router.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() }));
// Resource routes
router.use('/todos', todoRoutes);
router.use('/users', userRoutes);
// Admin routes (requires authentication + admin role)
router.use('/admin', authenticate, requireAdmin, adminRoutes);
module.exports = router;// app.js
const express = require('express');
const routes = require('./routes');
const app = express();
app.use(express.json());
app.use('/api', routes);
// All routes are now under /api/...4. Response Methods
Express provides several methods to send responses. Using the right one matters.
Essential Response Methods
// Send JSON (most common for APIs)
res.json({ success: true, data: user });
// Send plain text
res.send('Hello World');
// Set status code + JSON
res.status(201).json({ success: true, data: newUser });
// No content (after DELETE)
res.status(204).send();
// Redirect
res.redirect(301, '/new-url');
// Send a file
res.sendFile('/path/to/file.pdf');
// Trigger download
res.download('/path/to/report.pdf', 'report-2025.pdf');Setting Headers
// Set a single header
res.set('X-Request-Id', '123-abc');
// Set multiple headers
res.set({
'X-Request-Id': '123-abc',
'X-Response-Time': '42ms',
'Cache-Control': 'no-cache',
});
// Set a cookie
res.cookie('session', 'abc123', {
httpOnly: true,
secure: true,
maxAge: 3600000, // 1 hour in ms
});
// Clear a cookie
res.clearCookie('session');HTTP Status Codes You Should Know
| Code | Name | When to Use |
|---|---|---|
200 | OK | Successful GET, PUT, PATCH |
201 | Created | Successful POST that creates a resource |
204 | No Content | Successful DELETE |
400 | Bad Request | Invalid input, validation error |
401 | Unauthorized | Missing or invalid auth token |
403 | Forbidden | Valid token but insufficient permissions |
404 | Not Found | Resource doesn't exist |
409 | Conflict | Duplicate resource (e.g., email already exists) |
422 | Unprocessable Entity | Valid syntax but semantic error |
429 | Too Many Requests | Rate limit exceeded |
500 | Internal Server Error | Unexpected server error |
5. Input Validation with express-validator
Never trust user input. Even if your frontend validates, the API must validate too — anyone can send a request with curl or Postman.
Setup
npm install express-validatorBasic Validation
const { body, param, query, validationResult } = require('express-validator');
// Validation rules as middleware
const validateCreateTodo = [
body('title')
.trim()
.notEmpty()
.withMessage('Title is required')
.isLength({ max: 200 })
.withMessage('Title must be 200 characters or fewer'),
body('description')
.optional()
.trim()
.isLength({ max: 1000 })
.withMessage('Description must be 1000 characters or fewer'),
body('priority')
.optional()
.isIn(['low', 'medium', 'high'])
.withMessage('Priority must be low, medium, or high'),
];
// Middleware that checks validation results
function handleValidationErrors(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array().map((err) => ({
field: err.path,
message: err.msg,
})),
});
}
next();
}
// Apply to routes
router.post('/', validateCreateTodo, handleValidationErrors, (req, res) => {
// req.body is validated and sanitized here
const todo = createTodo(req.body);
res.status(201).json({ success: true, data: todo });
});Validating Parameters and Query Strings
const validateGetTodo = [
param('id')
.isInt({ min: 1 })
.withMessage('ID must be a positive integer'),
];
const validateListTodos = [
query('page')
.optional()
.isInt({ min: 1 })
.withMessage('Page must be a positive integer'),
query('limit')
.optional()
.isInt({ min: 1, max: 100 })
.withMessage('Limit must be between 1 and 100'),
query('sort')
.optional()
.isIn(['date', 'title', 'priority'])
.withMessage('Sort must be date, title, or priority'),
query('completed')
.optional()
.isBoolean()
.withMessage('Completed must be true or false'),
];
router.get('/', validateListTodos, handleValidationErrors, (req, res) => {
// Query params are validated
const todos = listTodos(req.query);
res.json({ success: true, data: todos });
});
router.get('/:id', validateGetTodo, handleValidationErrors, (req, res) => {
const todo = findTodo(req.params.id);
res.json({ success: true, data: todo });
});Reusable Validation Chains
Don't repeat validation logic across routes:
// validators/todo.js
const { body, param, query } = require('express-validator');
const todoId = param('id')
.isInt({ min: 1 })
.withMessage('ID must be a positive integer');
const todoTitle = body('title')
.trim()
.notEmpty()
.withMessage('Title is required')
.isLength({ max: 200 })
.withMessage('Title must be 200 characters or fewer');
const todoDescription = body('description')
.optional()
.trim()
.isLength({ max: 1000 })
.withMessage('Description must be 1000 characters or fewer');
const todoPriority = body('priority')
.optional()
.isIn(['low', 'medium', 'high'])
.withMessage('Priority must be low, medium, or high');
const pagination = [
query('page').optional().isInt({ min: 1 }),
query('limit').optional().isInt({ min: 1, max: 100 }),
];
module.exports = {
create: [todoTitle, todoDescription, todoPriority],
update: [todoId, todoTitle.optional(), todoDescription, todoPriority],
getOne: [todoId],
list: [...pagination],
};// routes/todos.js
const validate = require('../validators/todo');
const { handleValidationErrors } = require('../middleware/validate');
router.get('/', validate.list, handleValidationErrors, todoController.list);
router.get('/:id', validate.getOne, handleValidationErrors, todoController.getOne);
router.post('/', validate.create, handleValidationErrors, todoController.create);
router.put('/:id', validate.update, handleValidationErrors, todoController.update);Custom Validators
Sometimes the built-in validators aren't enough:
const { body } = require('express-validator');
const validateUniqueEmail = body('email')
.isEmail()
.withMessage('Must be a valid email')
.normalizeEmail()
.custom(async (email) => {
const existingUser = await User.findByEmail(email);
if (existingUser) {
throw new Error('Email already registered');
}
});
const validateDateRange = [
body('startDate')
.isISO8601()
.withMessage('Start date must be a valid ISO 8601 date'),
body('endDate')
.isISO8601()
.withMessage('End date must be a valid ISO 8601 date')
.custom((endDate, { req }) => {
if (new Date(endDate) <= new Date(req.body.startDate)) {
throw new Error('End date must be after start date');
}
return true;
}),
];6. File Uploads with Multer
Multer is the go-to middleware for handling multipart/form-data — the encoding type used for file uploads.
Setup
npm install multerBasic File Upload
const multer = require('multer');
const path = require('path');
// Simple storage: saves files with random names
const upload = multer({
dest: 'uploads/', // Directory for uploaded files
limits: {
fileSize: 5 * 1024 * 1024, // 5MB limit
},
});
// Single file upload
router.post('/avatar', upload.single('avatar'), (req, res) => {
// req.file contains upload information
console.log(req.file);
// {
// fieldname: 'avatar',
// originalname: 'photo.jpg',
// encoding: '7bit',
// mimetype: 'image/jpeg',
// destination: 'uploads/',
// filename: 'a1b2c3d4e5f6',
// path: 'uploads/a1b2c3d4e5f6',
// size: 234567
// }
res.json({
success: true,
data: {
filename: req.file.filename,
size: req.file.size,
},
});
});Custom Storage Configuration
For production, you want control over filenames and organization:
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
// unique-timestamp-originalname.ext
const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const ext = path.extname(file.originalname);
cb(null, `${uniqueName}${ext}`);
},
});
// File filter: only allow images
const fileFilter = (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only image files (JPEG, PNG, GIF, WebP) are allowed'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 5, // Max 5 files at once
},
});Multiple File Uploads
// Multiple files with the same field name
router.post('/gallery', upload.array('photos', 5), (req, res) => {
// req.files is an array
const uploaded = req.files.map((f) => ({
filename: f.filename,
size: f.size,
mimetype: f.mimetype,
}));
res.json({ success: true, data: uploaded });
});
// Different fields for different files
router.post(
'/document',
upload.fields([
{ name: 'cover', maxCount: 1 },
{ name: 'attachments', maxCount: 3 },
]),
(req, res) => {
const cover = req.files['cover']?.[0];
const attachments = req.files['attachments'] || [];
res.json({
success: true,
data: {
cover: cover?.filename,
attachments: attachments.map((f) => f.filename),
},
});
}
);Handling Upload Errors
Multer errors need to be caught specifically:
function handleUploadError(err, req, res, next) {
if (err instanceof multer.MulterError) {
// Multer-specific error
const messages = {
LIMIT_FILE_SIZE: 'File is too large. Maximum size is 5MB.',
LIMIT_FILE_COUNT: 'Too many files. Maximum is 5.',
LIMIT_UNEXPECTED_FILE: 'Unexpected file field.',
};
return res.status(400).json({
success: false,
message: messages[err.code] || 'File upload error',
});
}
if (err) {
// Custom error (e.g., from fileFilter)
return res.status(400).json({
success: false,
message: err.message,
});
}
next();
}
// Apply after upload routes
router.use(handleUploadError);7. RESTful API Design
REST (Representational State Transfer) isn't a library — it's a set of conventions for designing APIs that are consistent, predictable, and easy to use.
Resource Naming
Resources are nouns, not verbs. Use plural forms:
✅ Good ❌ Bad
GET /api/users GET /api/getUsers
GET /api/users/42 GET /api/getUserById
POST /api/users POST /api/createUser
PUT /api/users/42 POST /api/updateUser
DELETE /api/users/42 POST /api/deleteUserNested Resources
Use nesting for resources that belong to other resources:
GET /api/users/42/posts # All posts by user 42
POST /api/users/42/posts # Create a post for user 42
GET /api/users/42/posts/7 # Post 7 by user 42
GET /api/posts/7/comments # All comments on post 7
POST /api/posts/7/comments # Add a comment to post 7Rule of thumb: Limit nesting to 2 levels. Beyond that, use query parameters:
# Instead of /api/users/42/posts/7/comments/3/replies
GET /api/replies?commentId=3Consistent Response Format
Pick a format and use it everywhere:
// Success response
{
"success": true,
"data": { ... }, // or [...] for lists
"pagination": { // only for list endpoints
"page": 1,
"limit": 10,
"total": 42,
"totalPages": 5
}
}
// Error response
{
"success": false,
"message": "Validation failed",
"errors": [ // optional: detailed errors
{ "field": "email", "message": "Must be a valid email" }
]
}Implement a Response Helper
// utils/response.js
function sendSuccess(res, data, statusCode = 200) {
res.status(statusCode).json({
success: true,
data,
});
}
function sendPaginated(res, data, pagination) {
res.json({
success: true,
data,
pagination: {
page: pagination.page,
limit: pagination.limit,
total: pagination.total,
totalPages: Math.ceil(pagination.total / pagination.limit),
},
});
}
function sendError(res, message, statusCode = 500, errors = null) {
const response = { success: false, message };
if (errors) response.errors = errors;
res.status(statusCode).json(response);
}
module.exports = { sendSuccess, sendPaginated, sendError };// Using the helpers
const { sendSuccess, sendPaginated } = require('../utils/response');
router.get('/', (req, res) => {
const { todos, total } = getTodos(req.query);
sendPaginated(res, todos, {
page: req.query.page || 1,
limit: req.query.limit || 10,
total,
});
});
router.post('/', (req, res) => {
const todo = createTodo(req.body);
sendSuccess(res, todo, 201);
});8. Putting It All Together — Complete Project
Let's build a complete task management API that uses everything we've covered: Router, validation, file uploads, and RESTful design.
Project Structure
project/
├── package.json
├── src/
│ ├── app.js
│ ├── server.js
│ ├── routes/
│ │ ├── index.js
│ │ ├── todos.js
│ │ └── users.js
│ ├── controllers/
│ │ ├── todoController.js
│ │ └── userController.js
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── validate.js
│ │ └── errorHandler.js
│ ├── validators/
│ │ ├── todo.js
│ │ └── user.js
│ ├── utils/
│ │ ├── response.js
│ │ └── errors.js
│ └── data/
│ └── store.js
└── uploads/Data Store (In-Memory)
// src/data/store.js
let users = [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
];
let todos = [
{
id: 1,
userId: 1,
title: 'Learn Express Router',
description: 'Organize routes into modules',
priority: 'high',
completed: false,
attachment: null,
createdAt: new Date().toISOString(),
},
{
id: 2,
userId: 2,
title: 'Add validation',
description: 'Use express-validator',
priority: 'medium',
completed: false,
attachment: null,
createdAt: new Date().toISOString(),
},
];
let nextUserId = 3;
let nextTodoId = 3;
module.exports = {
users,
todos,
getNextUserId: () => nextUserId++,
getNextTodoId: () => nextTodoId++,
};Custom Errors
// src/utils/errors.js
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 = 'Validation failed', errors = []) {
super(message, 400);
this.errors = errors;
}
}
class ConflictError extends AppError {
constructor(message = 'Resource already exists') {
super(message, 409);
}
}
module.exports = { AppError, NotFoundError, ValidationError, ConflictError };Response Helpers
// src/utils/response.js
function sendSuccess(res, data, statusCode = 200) {
res.status(statusCode).json({ success: true, data });
}
function sendPaginated(res, data, pagination) {
res.json({
success: true,
data,
pagination: {
page: pagination.page,
limit: pagination.limit,
total: pagination.total,
totalPages: Math.ceil(pagination.total / pagination.limit),
},
});
}
module.exports = { sendSuccess, sendPaginated };Validators
// src/validators/todo.js
const { body, param, query } = require('express-validator');
const todoId = param('id')
.isInt({ min: 1 })
.withMessage('Todo ID must be a positive integer');
const todoTitle = body('title')
.trim()
.notEmpty()
.withMessage('Title is required')
.isLength({ max: 200 })
.withMessage('Title must be 200 characters or fewer');
const todoDescription = body('description')
.optional()
.trim()
.isLength({ max: 1000 })
.withMessage('Description must be 1000 characters or fewer');
const todoPriority = body('priority')
.optional()
.isIn(['low', 'medium', 'high'])
.withMessage('Priority must be low, medium, or high');
module.exports = {
create: [todoTitle, todoDescription, todoPriority],
update: [todoId, todoTitle.optional(), todoDescription, todoPriority],
getOne: [todoId],
list: [
query('page').optional().isInt({ min: 1 }),
query('limit').optional().isInt({ min: 1, max: 100 }),
query('priority').optional().isIn(['low', 'medium', 'high']),
query('completed').optional().isBoolean(),
],
};// src/validators/user.js
const { body, param } = require('express-validator');
const { users } = require('../data/store');
module.exports = {
create: [
body('name')
.trim()
.notEmpty()
.withMessage('Name is required')
.isLength({ max: 100 })
.withMessage('Name must be 100 characters or fewer'),
body('email')
.isEmail()
.withMessage('Must be a valid email')
.normalizeEmail()
.custom((email) => {
if (users.find((u) => u.email === email)) {
throw new Error('Email already registered');
}
return true;
}),
body('role')
.optional()
.isIn(['user', 'admin'])
.withMessage('Role must be user or admin'),
],
getOne: [
param('id').isInt({ min: 1 }).withMessage('User ID must be a positive integer'),
],
};Validation Middleware
// src/middleware/validate.js
const { validationResult } = require('express-validator');
function handleValidationErrors(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array().map((err) => ({
field: err.path,
message: err.msg,
})),
});
}
next();
}
module.exports = { handleValidationErrors };Error Handler
// src/middleware/errorHandler.js
const multer = require('multer');
function errorHandler(err, req, res, next) {
// Multer errors
if (err instanceof multer.MulterError) {
const messages = {
LIMIT_FILE_SIZE: 'File is too large. Maximum size is 5MB.',
LIMIT_FILE_COUNT: 'Too many files.',
LIMIT_UNEXPECTED_FILE: 'Unexpected file field.',
};
return res.status(400).json({
success: false,
message: messages[err.code] || 'File upload error',
});
}
// Custom AppError
if (err.isOperational) {
return res.status(err.statusCode).json({
success: false,
message: err.message,
...(err.errors && { errors: err.errors }),
});
}
// Unexpected errors
console.error('Unexpected error:', err);
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
module.exports = errorHandler;Controllers
// src/controllers/todoController.js
const { todos, getNextTodoId } = require('../data/store');
const { sendSuccess, sendPaginated } = require('../utils/response');
const { NotFoundError } = require('../utils/errors');
exports.list = (req, res) => {
let filtered = [...todos];
// Filter by priority
if (req.query.priority) {
filtered = filtered.filter((t) => t.priority === req.query.priority);
}
// Filter by completed status
if (req.query.completed !== undefined) {
const completed = req.query.completed === 'true';
filtered = filtered.filter((t) => t.completed === completed);
}
// Pagination
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const start = (page - 1) * limit;
const paginatedTodos = filtered.slice(start, start + limit);
sendPaginated(res, paginatedTodos, { page, limit, total: filtered.length });
};
exports.getOne = (req, res) => {
const todo = todos.find((t) => t.id === parseInt(req.params.id));
if (!todo) throw new NotFoundError('Todo');
sendSuccess(res, todo);
};
exports.create = (req, res) => {
const todo = {
id: getNextTodoId(),
userId: req.user?.id || null,
title: req.body.title,
description: req.body.description || null,
priority: req.body.priority || 'medium',
completed: false,
attachment: req.file?.filename || null,
createdAt: new Date().toISOString(),
};
todos.push(todo);
sendSuccess(res, todo, 201);
};
exports.update = (req, res) => {
const index = todos.findIndex((t) => t.id === parseInt(req.params.id));
if (index === -1) throw new NotFoundError('Todo');
todos[index] = {
...todos[index],
...(req.body.title && { title: req.body.title }),
...(req.body.description !== undefined && { description: req.body.description }),
...(req.body.priority && { priority: req.body.priority }),
...(req.body.completed !== undefined && { completed: req.body.completed }),
};
sendSuccess(res, todos[index]);
};
exports.remove = (req, res) => {
const index = todos.findIndex((t) => t.id === parseInt(req.params.id));
if (index === -1) throw new NotFoundError('Todo');
todos.splice(index, 1);
res.status(204).send();
};// src/controllers/userController.js
const { users, getNextUserId } = require('../data/store');
const { sendSuccess } = require('../utils/response');
const { NotFoundError } = require('../utils/errors');
exports.list = (req, res) => {
// Don't expose sensitive data
const safeUsers = users.map(({ id, name, email, role }) => ({
id,
name,
email,
role,
}));
sendSuccess(res, safeUsers);
};
exports.getOne = (req, res) => {
const user = users.find((u) => u.id === parseInt(req.params.id));
if (!user) throw new NotFoundError('User');
const { id, name, email, role } = user;
sendSuccess(res, { id, name, email, role });
};
exports.create = (req, res) => {
const user = {
id: getNextUserId(),
name: req.body.name,
email: req.body.email,
role: req.body.role || 'user',
};
users.push(user);
sendSuccess(res, user, 201);
};Routes
// src/routes/todos.js
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const todoController = require('../controllers/todoController');
const todoValidator = require('../validators/todo');
const { handleValidationErrors } = require('../middleware/validate');
// Multer config for todo attachments
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'uploads/'),
filename: (req, file, cb) => {
const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
cb(null, `${uniqueName}${path.extname(file.originalname)}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'application/pdf'];
cb(null, allowed.includes(file.mimetype));
},
});
// GET /api/todos
router.get('/', todoValidator.list, handleValidationErrors, todoController.list);
// GET /api/todos/:id
router.get('/:id', todoValidator.getOne, handleValidationErrors, todoController.getOne);
// POST /api/todos (with optional file attachment)
router.post(
'/',
upload.single('attachment'),
todoValidator.create,
handleValidationErrors,
todoController.create
);
// PUT /api/todos/:id
router.put('/:id', todoValidator.update, handleValidationErrors, todoController.update);
// DELETE /api/todos/:id
router.delete('/:id', todoValidator.getOne, handleValidationErrors, todoController.remove);
module.exports = router;// src/routes/users.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const userValidator = require('../validators/user');
const { handleValidationErrors } = require('../middleware/validate');
router.get('/', userController.list);
router.get('/:id', userValidator.getOne, handleValidationErrors, userController.getOne);
router.post('/', userValidator.create, handleValidationErrors, userController.create);
module.exports = router;// src/routes/index.js
const express = require('express');
const router = express.Router();
const todoRoutes = require('./todos');
const userRoutes = require('./users');
router.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
router.use('/todos', todoRoutes);
router.use('/users', userRoutes);
module.exports = router;App and Server
// src/app.js
const express = require('express');
const morgan = require('morgan');
const cors = require('cors');
const helmet = require('helmet');
const routes = require('./routes');
const errorHandler = require('./middleware/errorHandler');
const app = express();
// ─── Security & Logging ──────────────────────────────────────────────
app.use(helmet());
app.use(cors());
app.use(morgan('dev'));
// ─── Body Parsing ────────────────────────────────────────────────────
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// ─── Static Files (uploaded attachments) ─────────────────────────────
app.use('/uploads', express.static('uploads'));
// ─── Routes ──────────────────────────────────────────────────────────
app.use('/api', routes);
// ─── 404 Handler ─────────────────────────────────────────────────────
app.use((req, res) => {
res.status(404).json({
success: false,
message: `Cannot ${req.method} ${req.originalUrl}`,
});
});
// ─── Error Handler ───────────────────────────────────────────────────
app.use(errorHandler);
module.exports = app;// src/server.js
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(`Health check: http://localhost:${PORT}/api/health`);
});Testing the API
Start the server and test with curl:
node src/server.jsList todos with pagination:
curl http://localhost:3000/api/todos?page=1&limit=5Create a todo:
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "Write tests", "priority": "high"}'Create a todo with an attachment:
curl -X POST http://localhost:3000/api/todos \
-F "title=Upload example" \
-F "priority=medium" \
-F "attachment=@./screenshot.png"Update a todo:
curl -X PUT http://localhost:3000/api/todos/1 \
-H "Content-Type: application/json" \
-d '{"completed": true}'Delete a todo:
curl -X DELETE http://localhost:3000/api/todos/1Test validation error:
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"priority": "urgent"}'Expected error:
{
"success": false,
"message": "Validation failed",
"errors": [
{ "field": "title", "message": "Title is required" },
{ "field": "priority", "message": "Priority must be low, medium, or high" }
]
}9. Common Mistakes
❌ Defining routes after the 404 handler
app.use((req, res) => res.status(404).json({ message: 'Not found' }));
app.use('/api/todos', todoRoutes); // ← Never reached!✅ Always define routes BEFORE catch-all handlers
app.use('/api/todos', todoRoutes);
app.use((req, res) => res.status(404).json({ message: 'Not found' }));❌ Forgetting mergeParams for nested routers
const router = express.Router(); // No mergeParams
router.get('/', (req, res) => {
console.log(req.params.userId); // undefined!
});✅ Always set mergeParams: true for nested routers
const router = express.Router({ mergeParams: true });
router.get('/', (req, res) => {
console.log(req.params.userId); // "42" ✓
});❌ Not validating query parameters
router.get('/todos', (req, res) => {
const page = req.query.page; // Could be "abc", "-5", or an array!
});✅ Validate and convert every input
router.get('/todos', validateListTodos, handleValidationErrors, (req, res) => {
const page = parseInt(req.query.page) || 1; // Safe
});❌ Sending multiple responses
router.get('/:id', (req, res) => {
if (!todo) {
res.status(404).json({ message: 'Not found' });
// Forgot return — code keeps running!
}
res.json({ data: todo }); // Error: headers already sent
});✅ Always return after sending a response
router.get('/:id', (req, res) => {
if (!todo) {
return res.status(404).json({ message: 'Not found' });
}
res.json({ data: todo });
});Summary and Key Takeaways
Key Takeaways:
✅ Use Express Router to organize routes into modular, maintainable files
✅ Route parameters (req.params) identify resources, query strings (req.query) filter them
✅ Use mergeParams: true when nesting routers
✅ Validate all user input with express-validator before your business logic
✅ Use multer for file uploads with proper size limits and type filtering
✅ Follow REST conventions: plural nouns, proper HTTP methods, consistent response format
✅ Separate concerns: routes → validators → controllers → response helpers
✅ Return correct HTTP status codes for every operation
Practice Exercises
-
Add search to the todo list — Implement a
?search=keywordquery parameter that filters todos by title and description (case-insensitive). Add express-validator rules for it. -
Build a comments system — Create a nested
POST /api/todos/:id/commentsendpoint with its own router (usingmergeParams). Each comment should haveid,text,author, andcreatedAt. -
Implement PATCH vs PUT — Add a
PATCH /api/todos/:idroute that only updates fields that are sent (partial update), whilePUT /api/todos/:idrequires all fields (full replacement). Add separate validators for each. -
Add pagination headers — Instead of including pagination in the response body, set it in response headers:
X-Total-Count,X-Total-Pages,X-Current-Page, and add aLinkheader withrel="next"andrel="prev"URLs. -
Multi-file todo attachments — Modify the todo create/update endpoints to accept up to 3 attachments. Store attachment metadata (filename, size, mimetype) as an array on the todo object. Add an endpoint to delete individual attachments.
What's Next?
In the next post, we'll connect this API to a real database — MongoDB with Mongoose:
- Setting up MongoDB locally and in the cloud
- Mongoose schemas and models
- CRUD operations with Mongoose
- Query building, population, and pagination
- Data validation at the database level
Everything we built here — the Router structure, validators, controllers — will carry forward as we add database persistence.
Additional Resources
- Express Routing Guide
- express-validator Docs
- Multer Docs
- HTTP Status Codes Reference
- REST API Design Best Practices
Next Steps in This Series:
- Post 4: Working with MongoDB and Mongoose
- Post 5: Working with PostgreSQL and Prisma
- Post 6: Database Best Practices and Performance
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.