Skip to content

ExpressJS - RESTful APIs

Building Modern RESTful APIs with Express.js

Section titled “Building Modern RESTful APIs with Express.js”

A RESTful API (Representational State Transfer) is an architectural style for designing networked applications. It uses a stateless, client-server communication model, and leverages standard HTTP methods. For Express applications, this means creating well-structured endpoints that are intuitive, predictable, and easy for client applications (like mobile apps or single-page web apps) to consume.

A well-designed RESTful API uses HTTP verbs and resource-based URLs logically. Here’s a standard convention for a movies resource:

MethodURIActionDescription
GET/api/moviesReadRetrieves a list of all movies.
GET/api/movies/:idReadRetrieves a single movie by its unique ID.
POST/api/moviesCreateCreates a new movie using the data in the request body.
PUT/api/movies/:idUpdateReplaces an entire existing movie with new data.
PATCH/api/movies/:idUpdatePartially updates an existing movie with new data.
DELETE/api/movies/:idDeleteDeletes a movie by its unique ID.

Let’s build this API following modern best practices. We’ll use an in-memory array for simplicity, but the structure is designed to easily swap in a real database.

We will structure our code to separate concerns: routes, controllers, and data logic.

This file sets up the Express app, applies global middleware, and mounts our API routes. Notice that body-parser is no longer needed; we use the built-in express.json().

import express from 'express';
import movieRoutes from './movie.routes.js';
const app = express();
// Global middleware to parse JSON request bodies
app.use(express.json());
// Mount the movie router on the /api/movies path
app.use('/api/movies', movieRoutes);
// A simple 404 handler
app.use((req, res, next) => {
res.status(404).json({ message: 'Resource not found' });
});
export default app; // Export for testing and server.js

2. In-Memory Data and Router (movie.routes.js)

Section titled “2. In-Memory Data and Router (movie.routes.js)”

This file defines the routes and connects them to controller functions. It also holds our temporary in-memory data store.

import { Router } from 'express';
import {
getAllMovies,
getMovieById,
createMovie,
updateMovie,
deleteMovie,
} from './movie.controller.js';
const router = Router();
// In-memory data store (replace with a database in a real app)
export let movies = [
{ id: 1, title: 'Fight Club', year: 1999, rating: 8.8 },
{ id: 2, title: 'Inception', year: 2010, rating: 8.7 },
{ id: 3, title: 'The Dark Knight', year: 2008, rating: 9.0 },
];
router.route('/')
.get(getAllMovies)
.post(createMovie);
router.route('/:id')
.get(getMovieById)
.put(updateMovie)
.delete(deleteMovie);
export default router;

Controllers handle the request and response cycle. They contain the main logic for each route, keeping the route definitions clean. Note: In a real app, the logic for finding/updating data would be in a separate ‘service’ or ‘model’ layer.

import { movies } from './movie.routes.js'; // Note: This tight coupling is for example simplicity
let nextId = 4; // Simple ID incrementer
// GET /api/movies
export const getAllMovies = (req, res) => {
res.status(200).json(movies);
};
// GET /api/movies/:id
export const getMovieById = (req, res) => {
const movie = movies.find(m => m.id === parseInt(req.params.id));
if (!movie) {
return res.status(404).json({ message: 'Movie not found' });
}
res.status(200).json(movie);
};
// POST /api/movies
export const createMovie = (req, res) => {
const { title, year, rating } = req.body;
// Basic validation
if (!title || !year || !rating) {
return res.status(400).json({ message: 'Bad Request: Missing required fields' });
}
const newMovie = { id: nextId++, title, year, rating };
movies.push(newMovie);
res.status(201).json({ message: 'New movie created.', location: `/api/movies/${newMovie.id}` });
};
// PUT /api/movies/:id
export const updateMovie = (req, res) => {
const movieIndex = movies.findIndex(m => m.id === parseInt(req.params.id));
if (movieIndex === -1) {
return res.status(404).json({ message: 'Movie not found' });
}
const { title, year, rating } = req.body;
if (!title || !year || !rating) {
return res.status(400).json({ message: 'Bad Request: Missing required fields' });
}
const updatedMovie = { id: parseInt(req.params.id), title, year, rating };
movies[movieIndex] = updatedMovie;
res.status(200).json({ message: `Movie with id ${req.params.id} updated.`, movie: updatedMovie });
};
// DELETE /api/movies/:id
export const deleteMovie = (req, res) => {
const movieIndex = movies.findIndex(m => m.id === parseInt(req.params.id));
if (movieIndex === -1) {
return res.status(404).json({ message: 'Movie not found' });
}
movies.splice(movieIndex, 1);
res.status(204).send(); // 204 No Content is a common response for successful deletion
};

You can use a tool like curl or Postman to interact with your running API.

curl -X POST http://localhost:3000/api/movies \
-H "Content-Type: application/json" \
-d '{"title": "The Matrix", "year": 1999, "rating": 8.7}'

Response:

{
"message": "New movie created.",
"location": "/api/movies/4"
}
curl http://localhost:3000/api/movies/3

Response:

{
"id": 3,
"title": "The Dark Knight",
"year": 2008,
"rating": 9.0
}
  • Input Validation: The current validation is basic. Use a library like joi or express-validator to create robust, reusable validation schemas that are applied as middleware before the controller logic runs.
  • Database Integration: Replace the in-memory movies array with a persistent database connection (e.g., MongoDB with Mongoose, or PostgreSQL with an ORM like Sequelize or Prisma).
  • Centralized Error Handling: Implement a global error-handling middleware to catch errors from controllers (especially from async database operations) and return consistent, formatted error responses.