Back to blog

Express.js Routing and Request Handling

expressnodejsbackendjavascriptrest-api
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 classes

2. 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 SourceUse CaseExample
req.paramsIdentify a specific resourceGET /users/42
req.queryFilter, sort, paginateGET /users?role=admin&page=2
req.bodySubmit data (create/update)POST /users with JSON body
req.headersAuth tokens, content typeAuthorization: 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

CodeNameWhen to Use
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST that creates a resource
204No ContentSuccessful DELETE
400Bad RequestInvalid input, validation error
401UnauthorizedMissing or invalid auth token
403ForbiddenValid token but insufficient permissions
404Not FoundResource doesn't exist
409ConflictDuplicate resource (e.g., email already exists)
422Unprocessable EntityValid syntax but semantic error
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnexpected 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-validator

Basic 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 multer

Basic 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/deleteUser

Nested 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 7

Rule 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=3

Consistent 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.js

List todos with pagination:

curl http://localhost:3000/api/todos?page=1&limit=5

Create 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/1

Test 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

  1. Add search to the todo list — Implement a ?search=keyword query parameter that filters todos by title and description (case-insensitive). Add express-validator rules for it.

  2. Build a comments system — Create a nested POST /api/todos/:id/comments endpoint with its own router (using mergeParams). Each comment should have id, text, author, and createdAt.

  3. Implement PATCH vs PUT — Add a PATCH /api/todos/:id route that only updates fields that are sent (partial update), while PUT /api/todos/:id requires all fields (full replacement). Add separate validators for each.

  4. 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 a Link header with rel="next" and rel="prev" URLs.

  5. 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

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.