Node.js & Express

Building RESTful APIs with Express

18 min Lesson 11 of 40

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:

  1. Create endpoints for posts: GET (all/single), POST, PUT, DELETE
  2. Implement proper HTTP status codes
  3. Add pagination and sorting to GET all posts
  4. Create nested routes for comments: GET /posts/:id/comments
  5. Implement API versioning (/api/v1)
  6. Use consistent JSON response format with success, data, and message fields