Building RESTful APIs with Express
RESTful APIs are the backbone of modern web applications, enabling communication between frontend clients and backend servers. In this lesson, we'll explore how to design and build robust RESTful APIs using Express.js.
Understanding REST Principles
REST (Representational State Transfer) is an architectural style that defines a set of constraints for creating web services. Key principles include:
- Stateless: Each request contains all necessary information; the server stores no client context
- Client-Server Separation: Frontend and backend are independent and communicate via HTTP
- Uniform Interface: Resources are accessed using standard HTTP methods
- Resource-Based: Everything is a resource identified by URIs
- Cacheable: Responses can be cached for performance optimization
Note: RESTful APIs use HTTP methods semantically: GET for retrieval, POST for creation, PUT/PATCH for updates, and DELETE for removal.
Designing API Endpoints
Good API design follows consistent patterns and uses proper HTTP methods:
// Resource-based endpoint structure
GET /api/users // Get all users
GET /api/users/:id // Get single user
POST /api/users // Create new user
PUT /api/users/:id // Update entire user
PATCH /api/users/:id // Partial update user
DELETE /api/users/:id // Delete user
// Nested resources
GET /api/users/:id/posts // Get user's posts
POST /api/users/:id/posts // Create post for user
GET /api/posts/:id/comments // Get post's comments
Best Practice: Use plural nouns for resources (users, posts, comments) and avoid verbs in endpoints. The HTTP method provides the verb.
JSON Responses and Status Codes
RESTful APIs communicate using JSON format and appropriate HTTP status codes:
const express = require('express');
const router = express.Router();
// GET all users - 200 OK
router.get('/users', async (req, res) => {
try {
const users = await User.find();
res.status(200).json({
success: true,
count: users.length,
data: users
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Server error'
});
}
});
// GET single user - 200 OK or 404 Not Found
router.get('/users/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
res.status(200).json({
success: true,
data: user
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Server error'
});
}
});
CRUD Operations Pattern
Implementing complete CRUD (Create, Read, Update, Delete) operations:
// CREATE - 201 Created
router.post('/users', async (req, res) => {
try {
const { name, email, password } = req.body;
// Check if user exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({
success: false,
message: 'User already exists'
});
}
const user = await User.create({ name, email, password });
res.status(201).json({
success: true,
data: user
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message
});
}
});
// UPDATE - 200 OK
router.put('/users/:id', async (req, res) => {
try {
const user = await User.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
res.status(200).json({
success: true,
data: user
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message
});
}
});
// DELETE - 204 No Content
router.delete('/users/:id', async (req, res) => {
try {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
res.status(204).send();
} catch (error) {
res.status(500).json({
success: false,
message: 'Server error'
});
}
});
HTTP Status Codes:
- 200 OK: Successful GET, PUT, PATCH
- 201 Created: Successful POST
- 204 No Content: Successful DELETE
- 400 Bad Request: Invalid data
- 404 Not Found: Resource doesn't exist
- 500 Internal Server Error: Server issue
API Versioning
Versioning allows API evolution without breaking existing clients:
// URL versioning (most common)
app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);
// Header versioning
app.use((req, res, next) => {
const version = req.headers['api-version'] || 'v1';
req.apiVersion = version;
next();
});
// Example versioned structure
// routes/v1/users.js
const express = require('express');
const router = express.Router();
router.get('/users', (req, res) => {
res.json({ version: 'v1', data: [] });
});
module.exports = router;
// app.js
const v1Users = require('./routes/v1/users');
const v2Users = require('./routes/v2/users');
app.use('/api/v1', v1Users);
app.use('/api/v2', v2Users);
Best Practice: Use URL versioning (e.g., /api/v1/) as it's explicit, easy to test, and works well with browser tools.
Content Negotiation
Handling different response formats based on client preferences:
router.get('/users/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({
message: 'User not found'
});
}
// Content negotiation based on Accept header
res.format({
'application/json': () => {
res.json({
success: true,
data: user
});
},
'application/xml': () => {
res.type('application/xml');
res.send(`<user>
<id>${user.id}</id>
<name>${user.name}</name>
</user>`);
},
'text/html': () => {
res.render('user', { user });
},
'default': () => {
res.status(406).send('Not Acceptable');
}
});
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
});
Complete API Example
// controllers/userController.js
const User = require('../models/User');
exports.getUsers = async (req, res) => {
try {
const { page = 1, limit = 10, sort = 'createdAt' } = req.query;
const users = await User.find()
.limit(limit * 1)
.skip((page - 1) * limit)
.sort(sort);
const count = await User.countDocuments();
res.status(200).json({
success: true,
data: users,
pagination: {
page: Number(page),
limit: Number(limit),
totalPages: Math.ceil(count / limit),
totalItems: count
}
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
};
exports.createUser = async (req, res) => {
try {
const user = await User.create(req.body);
res.status(201).json({
success: true,
data: user
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message
});
}
};
// routes/users.js
const express = require('express');
const router = express.Router();
const {
getUsers,
getUser,
createUser,
updateUser,
deleteUser
} = require('../controllers/userController');
router.route('/')
.get(getUsers)
.post(createUser);
router.route('/:id')
.get(getUser)
.put(updateUser)
.delete(deleteUser);
module.exports = router;
// app.js
const userRoutes = require('./routes/users');
app.use('/api/v1/users', userRoutes);
Common Mistakes:
- Using verbs in endpoint names (/getUsers instead of GET /users)
- Not using appropriate HTTP status codes
- Exposing sensitive data in responses
- Not implementing pagination for large datasets
- Inconsistent response formats
Practice Exercise
Build a RESTful API for a blog system with the following requirements:
- Create endpoints for posts: GET (all/single), POST, PUT, DELETE
- Implement proper HTTP status codes
- Add pagination and sorting to GET all posts
- Create nested routes for comments: GET /posts/:id/comments
- Implement API versioning (/api/v1)
- Use consistent JSON response format with success, data, and message fields