Complete "Lesson 2: Setting Up a Node.js and Express Project" first to unlock this lesson.Go to previous lesson

Introduction

As your API grows beyond a handful of endpoints, dumping everything into a single file becomes unmanageable fast. Express gives you a built-in tool called Router to split routes into separate modules by resource. Pair that with a controllers layer and you have a structure that scales cleanly — new team members can find what they need, and adding a new resource does not touch existing code.

This lesson walks through a concrete project layout: routes define the URL patterns and HTTP verbs, controllers hold the actual handler logic, and app.js wires them together. That separation is the single most important habit to build early.

layered architecture diagram showing Request flowing into app.js, then Router layer, then Controller layer, then a placeholder Data layer

Project Structure

Here is the directory layout we will build toward:

text
project/
├── app.js
├── routes/
│ ├── users.js
│ └── products.js
└── controllers/
├── usersController.js
└── productsController.js

The pattern is simple: one route file per resource, one controller file per resource. Routes stay thin — they only declare which controller function handles each endpoint. Controllers stay focused — they only contain request/response logic for their resource.

Express Router

Express Router creates a mini-application that handles routes independently, then you mount it on a path in app.js.

Start with the users route file:

javascript
// routes/users.js
const express = require('express');
const router = express.Router();
const usersController = require('../controllers/usersController');
router.get('/', usersController.getAllUsers);
router.get('/:id', usersController.getUserById);
router.post('/', usersController.createUser);
router.put('/:id', usersController.updateUser);
router.delete('/:id', usersController.deleteUser);
module.exports = router;

Notice the routes use relative paths like / and /:id, not /users or /users/:id. The prefix gets added when you mount the router in app.js, so you never repeat yourself.

Mounting the router in app.js:

javascript
// app.js
const express = require('express');
const app = express();
app.use(express.json());
const usersRouter = require('./routes/users');
const productsRouter = require('./routes/products');
app.use('/users', usersRouter);
app.use('/products', productsRouter);
app.listen(3000, () => console.log('Server running on port 3000'));

A GET request to /users/42 now resolves to router.get('/:id', ...) with req.params.id equal to '42'. Clean and predictable.

Controllers

Controllers are plain modules that export named functions. Each function is a standard Express request handler — it receives req and res (and optionally next) and is responsible for sending a response.

javascript
// controllers/usersController.js
// Simulated in-memory data — you will replace this with a database call later
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
exports.getAllUsers = (req, res) => {
res.json(users);
};
exports.getUserById = (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
};
exports.createUser = (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ message: 'name and email are required' });
}
const newUser = { id: users.length + 1, name, email };
users.push(newUser);
res.status(201).json(newUser);
};
exports.updateUser = (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
const { name, email } = req.body;
if (name) user.name = name;
if (email) user.email = email;
res.json(user);
};
exports.deleteUser = (req, res) => {
const index = users.findIndex(u => u.id === parseInt(req.params.id));
if (index === -1) {
return res.status(404).json({ message: 'User not found' });
}
users.splice(index, 1);
res.status(204).send();
};

Several things worth noting here. The controller validates input and returns early with an appropriate status code when something is wrong — this keeps the happy path readable. The 404 check is explicit: do not assume a record exists. And 204 No Content with no body is the conventional response for a successful delete.

flowchart showing a single incoming HTTP request being matched by Router to the correct Controller function, then branching into success response vs error response

Common Mistakes

Forgetting to parse JSON request bodies is the most frequent first-time issue. Without app.use(express.json()) in app.js, req.body will be undefined and createUser or updateUser will silently receive nothing.

Another pitfall is comparing req.params.id (a string) directly to a numeric id with strict equality. The users.find(u => u.id === req.params.id) check will always fail because '42' !== 42. Use parseInt or Number() to convert first.

Finally, not returning after sending a response leads to a 'Cannot set headers after they are sent' error. Always use return res.status(404).json(...) — the return prevents Express from continuing to the next line and attempting a second response.

Summary

Express Router lets you group related routes into their own modules and mount them at a prefix in app.js. Controllers keep handler logic out of route files, making each layer easier to read, test, and extend. The pattern is: route file declares the URL and verb, controller function handles the logic and sends the response. Stick with one file per resource and this structure will hold up as the project grows.

Lesson practice

Lesson Checkpoint

Review what you learned and get feedback on your work.

0/1
Answered · 0/1
Lesson 3: Routing, Controllers, and Project Structure