Build a Product Catalog API

Build a production-structured REST API with layered architecture in Node.js.

Introduction

30 Second Summary

Every online store, delivery app, and booking service you use has a backend API serving data behind the scenes. When you search for products, filter by category, or add something to your cart, a structured API is doing the heavy lifting.

In this project, you will build a Product Catalog REST API from scratch using Node.js, Express, and MongoDB. You will organize it with the same layered architecture that professional backend teams use in production.

What You'll Build

Imagine sending a request to your API, creating a new product, and getting back a perfectly structured JSON response. Then try to break it with bad data or flood it with requests, and watch it handle every edge case gracefully without crashing.

By the end of this project, you'll have:

  • A working CRUD API where you can create, read, update, and delete products, getting back clean JSON responses for every operation.
  • Centralized error handling that catches bad requests and missing resources, returning structured errors with correct HTTP status codes instead of server crashes.
  • A security-hardened API protected by rate limiting, security headers, and request logging that tracks every incoming request in real time with method, URL, status code, and response time.
  • Secret Mission: Add filtering, sorting, and pagination to transform your GET endpoint into a production-ready query interface.

Are there any prerequisites?

This project assumes a basic understanding of REST APIs and CRUD operations with Express and MongoDB. You should be comfortable creating a basic Express server and connecting it to a database.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Project

Every production API starts with a clean foundation. The folder structure you create now determines how easily you can add features, debug issues, and onboard other developers later.

In this step, you'll scaffold a Node.js project with the layered folder structure that separates concerns from day one. You'll install all the dependencies, connect to MongoDB Atlas, and confirm the server is up and running.

In this step, get ready to:
  • Initialize a Node.js project and install all required dependencies.
  • Create the layered folder structure and environment configuration.
  • Wire up Express and MongoDB so the server starts and connects successfully.
Create the project and install dependencies
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir product-catalog-api
cd product-catalog-api
  • Initialize a new Node.js project by running:
npm init -y

You should see a JSON object printed in your terminal showing the generated package.json with default values.

  • Install all the project dependencies by running:
npm install express@5.2.1 mongoose@9.9.0 dotenv@17.4.2 express-validator@7.3.2 express-rate-limit@8.6.1 helmet@8.3.0 cors@2.8.6 morgan@1.11.0 cloudinary multer multer-storage-cloudinary-v2

What are all these packages?

  • express is the web framework for handling HTTP requests and routing.
  • mongoose connects your app to MongoDB and provides schema-based models.
  • dotenv loads environment variables from a .env file into process.env.
  • cloudinary is the SDK for uploading and managing images on Cloudinary's cloud service.
  • multer is middleware for handling multipart/form-data (file uploads) in Express.
  • multer-storage-cloudinary-v2 connects Multer directly to Cloudinary so uploaded files go straight to the cloud.
  • express-validator, express-rate-limit, helmet, and cors handle validation and security (you'll use these in later steps).
  • morgan logs every incoming request so you can see what your API is doing in real time.

Once the install finishes, you should see a message confirming packages were added. Your package.json now lists all 11 dependencies.

Seeing permission errors or EACCES?

On macOS, try prefixing the install command with sudo. On Windows, right-click your terminal and select Run as administrator.

Help me fix npm permission errors

  • Open the product-catalog-api folder in VS Code by running:
code .

Command not found when running code?

If code . does not work, open VS Code manually. Click File in the menu bar, then Open Folder, and select the product-catalog-api folder on your Desktop.

Create the folder structure and configuration

The layered architecture separates your code by responsibility. Each folder holds one type of concern: models define data shape, services hold business logic, controllers handle HTTP, and routes map URLs to controllers.

  • Create all the project folders by running this command in your VS Code terminal (open it with Ctrl+` or Cmd+`):
mkdir -p src/config src/models src/routes src/controllers src/services src/middleware
  • Confirm the folders were created by running:
ls src/

You should see config, controllers, middleware, models, routes, and services listed.

Your API needs two pieces of configuration: a port to listen on and a database connection string. Environment variables keep these values out of your code.

  • Create a .env file in the root of your product-catalog-api folder (not inside src/). Paste this content:
PORT=5000
MONGODB_URI=your-mongodb-atlas-connection-string-here
CORS_ORIGIN=*
CLOUDINARY_CLOUD_NAME=your-cloud-name-here
CLOUDINARY_API_KEY=your-api-key-here
CLOUDINARY_API_SECRET=your-api-secret-here
  • Replace your-mongodb-atlas-connection-string-here with your actual MongoDB Atlas connection string.
  • Replace the three CLOUDINARY_* values with your Cloudinary credentials. You can find these on your Cloudinary dashboard under Account Details.

Where do I find my MongoDB Atlas connection string?

  • Log in to your MongoDB Atlas account and go to your cluster's overview page.
  • Click the Connect button for your cluster.
  • Select Drivers as your connection method.
  • Copy the connection string. It looks like mongodb+srv://username:<db_password>@cluster0.xxxxx.mongodb.net/.
  • Replace <db_password> with your actual database user password.

Next, you need a function that connects your app to MongoDB. If the connection fails, the app should stop immediately so you know something is wrong.

  • Create a new file at src/config/db.js and paste this code:
const mongoose = require('mongoose');

// Connect to MongoDB Atlas using the URI from .env
const connectDB = async () => {
  try {
    await mongoose.connect(process.env.MONGODB_URI);
    console.log('MongoDB connected');
  } catch (error) {
    // Stop the app if the database is unreachable
    console.error('MongoDB connection error:', error.message);
    process.exit(1);
  }
};

module.exports = connectDB;

What does this code do?

  • mongoose.connect() opens a connection to your Atlas cluster using the URI stored in your .env file.
  • If the connection succeeds, it logs a confirmation. If it fails (wrong password, network issue), it logs the error and exits the process so you see the problem immediately.
  • Save src/config/db.js.
Create the Express app and start the server

The Express app is where you register middleware and routes. The server file imports that app, connects to the database, and starts listening for requests.

  • Create a new file at src/app.js and paste this code:
require('dotenv').config();
const express = require('express');
const morgan = require('morgan');

const app = express();

// Parse incoming JSON request bodies
app.use(express.json());
// Log every request with method, URL, status, and response time
app.use(morgan('dev'));

// Health check endpoint to verify the API is running
app.get('/api/v1/health', (req, res) => {
  res.json({ success: true, message: 'API is running' });
});

module.exports = app;

What does this code do?

  • require('dotenv').config() loads your .env variables into process.env so the rest of your app can use them.
  • express.json() parses JSON request bodies so you can read req.body in your controllers.
  • morgan('dev') logs each request in a color-coded format showing method, URL, status code, and response time.
  • The /api/v1/health route is a quick way to confirm the API is alive. The /api/v1/ prefix is API versioning, which lets you release breaking changes under /api/v2/ later without disrupting existing clients.
  • Save src/app.js.
  • Create a new file at src/server.js and paste this code:
const app = require('./app');
const connectDB = require('./config/db');

const PORT = process.env.PORT || 5000;

// Connect to MongoDB first, then start the server
connectDB().then(() => {
  app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
  });
});

What does this code do?

  • connectDB() runs first and returns a promise. Only after MongoDB connects successfully does the server start listening on port 5000.
  • This ensures your API never accepts requests before the database is ready. If the connection fails, process.exit(1) in db.js stops everything.
  • Save src/server.js.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all four files before running the server.

ⓧ I'd like to double check the full code

Here is each file you should have at this point. Compare yours against these:

PORT=5000
MONGODB_URI=your-mongodb-atlas-connection-string-here
CORS_ORIGIN=*
CLOUDINARY_CLOUD_NAME=your-cloud-name-here
CLOUDINARY_API_KEY=your-api-key-here
CLOUDINARY_API_SECRET=your-api-secret-here
const mongoose = require('mongoose');

const connectDB = async () => {
  try {
    await mongoose.connect(process.env.MONGODB_URI);
    console.log('MongoDB connected');
  } catch (error) {
    console.error('MongoDB connection error:', error.message);
    process.exit(1);
  }
};

module.exports = connectDB;
require('dotenv').config();
const express = require('express');
const morgan = require('morgan');

const app = express();

app.use(express.json());
app.use(morgan('dev'));

app.get('/api/v1/health', (req, res) => {
  res.json({ success: true, message: 'API is running' });
});

module.exports = app;
const app = require('./app');
const connectDB = require('./config/db');

const PORT = process.env.PORT || 5000;

connectDB().then(() => {
  app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
  });
});

Before you run the server, what do you expect to see in the terminal if everything is configured correctly?

  • Start the server by running:
node src/server.js

You should see two lines printed in your terminal:

MongoDB connected
Server running on port 5000

Both messages confirm your database connection and server are working. Your API is live and ready to receive requests.

Seeing a MongoDB connection error?

  • Double check that your MONGODB_URI in .env is the full connection string from Atlas, not the placeholder text.
  • Make sure you replaced <db_password> with your actual database user password (not your Atlas login password).
  • In Atlas, go to Network Access and confirm your current IP address is in the allowed list. Add 0.0.0.0/0 to allow access from anywhere during development.

Help me debug my MongoDB Atlas connection error

🙋‍♀️ Seeing Cannot find module errors?

  • Make sure you are running the command from the product-catalog-api folder (where package.json lives).
  • Confirm the node_modules folder exists. If not, run npm install again.
  • Check that your file paths match exactly: src/server.js imports from ./app and ./config/db.

Your project skeleton is ready with a clean separation of concerns and a confirmed database connection. Next up, you'll fill this structure with a full set of CRUD operations and immediately test them in Postman.

Build Full CRUD with Layered Architecture

Your server is running and connected to MongoDB, but it can only respond to health checks. To turn it into a real product catalog, you need endpoints that create, read, update, and delete products.

Instead of dumping all the logic into one file, you will organize code into distinct layers: a Model that defines the data shape, a Service that handles business logic, a Controller that manages HTTP requests and responses, and a Router that maps URLs to controller functions.

In this step, get ready to:
  • Create a Product model with Mongoose schema validation.
  • Build the service and controller layers for all five CRUD operations.
  • Wire up routes and test every operation in Postman.
Create the Product model

The Model layer defines what a product looks like in the database. Mongoose schemas let you declare field types, required fields, and constraints in one place.

  • Create a new file at src/models/product.model.js and add the following schema:
const mongoose = require('mongoose');

const productSchema = new mongoose.Schema({
  // Product name: required, trimmed, 3-100 characters
  name: {
    type: String,
    required: [true, 'Product name is required'],
    trim: true,
    minlength: [3, 'Name must be at least 3 characters'],
    maxlength: [100, 'Name cannot exceed 100 characters']
  },
  // Price: required, cannot be negative
  price: {
    type: Number,
    required: [true, 'Price is required'],
    min: [0, 'Price cannot be negative']
  },
  // Category: required, trimmed
  category: {
    type: String,
    required: [true, 'Category is required'],
    trim: true
  },
  // Description: optional, max 500 characters
  description: {
    type: String,
    trim: true,
    maxlength: [500, 'Description cannot exceed 500 characters']
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

module.exports = mongoose.model('Product', productSchema);

What does this code do?

Each field in the schema enforces constraints at the database level. The second argument in arrays like required: [true, 'Product name is required'] provides a custom error message that Mongoose returns when validation fails.

The trim: true option strips leading and trailing whitespace from strings. The final mongoose.model('Product', productSchema) line registers the model so Mongoose can query the products collection.

  • Save src/models/product.model.js.
  • In your terminal, stop the server with Ctrl+C and restart it by running:
node src/server.js
  • Confirm you still see "Server running on port 5000" and "MongoDB connected" with no errors.

Seeing a syntax error on startup?

Check that every opening brace { in the schema has a matching closing brace }. Also confirm the final line uses mongoose.model (lowercase m), not mongoose.Model.

Help me debug my Mongoose model syntax error

Create the service and controller layers

The Service layer contains pure data logic. It knows how to talk to Mongoose but knows nothing about HTTP. The Controller layer translates HTTP requests into service calls and formats the responses.

  • Create a new file at src/services/product.service.js and add the read and create operations:
const mongoose = require('mongoose');
const Product = require('../models/product.model');

// Create a new product document in MongoDB
const createProduct = async (data) => {
  const product = await Product.create(data);
  return product;
};

// Retrieve all products from the database
const getAllProducts = async () => {
  const products = await Product.find();
  return products;
};

// Find a single product by its MongoDB ID
const getProductById = async (id) => {
  const product = await Product.findById(id);
  return product;
};

What does this code do?

Each function is a thin wrapper around a Mongoose method. Product.create(data) inserts a new document. Product.find() returns all documents. Product.findById(id) locates one by its _id field.

  • Continue in the same file. Add the update and delete functions below getProductById, then export everything:
// Update a product and return the updated version
const updateProduct = async (id, data) => {
  const product = await Product.findByIdAndUpdate(id, data, {
    new: true,
    runValidators: true
  });
  return product;
};

// Remove a product from the database
const deleteProduct = async (id) => {
  const product = await Product.findByIdAndDelete(id);
  return product;
};

module.exports = {
  createProduct,
  getAllProducts,
  getProductById,
  updateProduct,
  deleteProduct
};

Why new: true and runValidators: true?

By default, findByIdAndUpdate returns the document as it was before the update. Setting new: true returns the modified document instead.

The runValidators: true option tells Mongoose to run schema validations on update operations. Without it, a user could update a product's price to a negative number and the schema constraints would be bypassed.

  • Save src/services/product.service.js.

Now the controller. Each controller function receives the HTTP request, calls the matching service function, and sends a JSON response back to the client.

  • Create a new file at src/controllers/product.controller.js and add the create, get-all, and get-by-ID handlers:
const productService = require('../services/product.service');

// POST /products - create a new product
const createProduct = async (req, res, next) => {
  try {
    const product = await productService.createProduct(req.body);
    res.status(201).json({ success: true, data: product });
  } catch (error) {
    next(error);
  }
};

// GET /products - list all products
const getAllProducts = async (req, res, next) => {
  try {
    const products = await productService.getAllProducts();
    res.json({ success: true, data: products });
  } catch (error) {
    next(error);
  }
};

// GET /products/:id - get one product by ID
const getProductById = async (req, res, next) => {
  try {
    const product = await productService.getProductById(req.params.id);
    res.json({ success: true, data: product });
  } catch (error) {
    next(error);
  }
};

What does this code do?

Every controller wraps its service call in a try/catch block. If the service succeeds, the controller sends a JSON response with success: true. If it throws, next(error) passes the error to Express's error handling chain.

The createProduct handler uses res.status(201) because 201 means "Created". The others default to 200 OK.

  • Continue in the same file. Add the update and delete handlers, then export all five functions:
// PUT /products/:id - update a product
const updateProduct = async (req, res, next) => {
  try {
    const product = await productService.updateProduct(req.params.id, req.body);
    res.json({ success: true, data: product });
  } catch (error) {
    next(error);
  }
};

// DELETE /products/:id - remove a product
const deleteProduct = async (req, res, next) => {
  try {
    const product = await productService.deleteProduct(req.params.id);
    res.json({ success: true, message: 'Product deleted successfully' });
  } catch (error) {
    next(error);
  }
};

module.exports = {
  createProduct,
  getAllProducts,
  getProductById,
  updateProduct,
  deleteProduct
};
  • Save src/controllers/product.controller.js.
  • Stop and restart the server to check for errors:
node src/server.js
  • Confirm "Server running on port 5000" and "MongoDB connected" appear again with no errors.

Seeing "Cannot find module" on startup?

This means a require() path is wrong. Check that product.service.js uses require('../models/product.model') and product.controller.js uses require('../services/product.service'). The relative paths must match the folder structure exactly.

Help me fix a Cannot find module error in my product API

Wire up routes and test in Postman

The Router maps HTTP methods and URL paths to controller functions. Once the router is connected to the Express app, your API is live.

  • Create a new file at src/routes/product.routes.js and define all five routes:
const express = require('express');
const router = express.Router();
const {
  createProduct,
  getAllProducts,
  getProductById,
  updateProduct,
  deleteProduct
} = require('../controllers/product.controller');

// Map HTTP methods to controller functions
router.post('/', createProduct);
router.get('/', getAllProducts);
router.get('/:id', getProductById);
router.put('/:id', updateProduct);
router.delete('/:id', deleteProduct);

module.exports = router;

What does this code do?

The router handles paths relative to wherever it gets mounted. Since you will mount it at /api/v1/products, a router.get('/:id') here actually responds to GET /api/v1/products/:id.

  • Save src/routes/product.routes.js.
  • Now wire the router into your app. In src/app.js, find this line near the top:
const morgan = require('morgan');
  • Add this import directly below it:
const productRoutes = require('./routes/product.routes');
  • Next, find the health check route:
app.get('/api/v1/health', (req, res) => {
  res.json({ success: true, message: 'API is running' });
});
  • Add this line directly below the health check to mount the product routes:
app.use('/api/v1/products', productRoutes);

Why /api/v1/ in the path?

The /api/v1/ prefix is an API versioning convention. It lets you release breaking changes under /api/v2/ in the future without disrupting existing clients. It costs nothing to add now but is painful to retrofit later.

  • Save src/app.js and restart the server:
node src/server.js

Before you test in Postman, what do you think will happen when you POST a product? Will it return the product data, or will something go wrong?

  • Open Postman.
  • Click the + button to create a new request tab.
  • Change the method dropdown from GET to POST.
  • Enter this URL:
http://localhost:5000/api/v1/products
  • Click the Body tab below the URL bar.
  • Select raw, then change the dropdown on the right from Text to JSON.
  • Paste this JSON body:
{
  "name": "Laptop",
  "price": 999.99,
  "category": "Electronics",
  "description": "A powerful laptop"
}
  • Click Send.

You should see a 201 Created response with the product data including a generated _id and createdAt timestamp.

  • Now change the method to GET (same URL) and click Send.

You should see a 200 OK response with an array containing the laptop you just created.

  • Check your terminal where the server is running.

Morgan logs each request. You should see lines like POST /api/v1/products 201 and GET /api/v1/products 200 with response times in milliseconds.

Getting a 404 or cannot connect?

Make sure your server is still running (you should see "Server running on port 5000" in the terminal). Confirm the URL is exactly http://localhost:5000/api/v1/products with no typos.

If you see a 404, check that src/app.js has both the require('./routes/product.routes') import and the app.use('/api/v1/products', productRoutes) line.

Help me troubleshoot my Express routes not responding

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: product.model.js, product.service.js, product.controller.js, and product.routes.js.

ⓧ I'd like to double check the full code

Scroll up and confirm each code chunk is present in order. Your files should contain:

  • src/models/product.model.js - the full Mongoose schema with name, price, category, description, and createdAt fields.
  • src/services/product.service.js - five exported functions: createProduct, getAllProducts, getProductById, updateProduct, deleteProduct.
  • src/controllers/product.controller.js - five exported handlers, each wrapping a service call in try/catch with next(error).
  • src/routes/product.routes.js - router mapping POST, GET, GET/:id, PUT/:id, DELETE/:id to controller functions.

Your src/app.js should now look like this:

require('dotenv').config();
const express = require('express');
const morgan = require('morgan');
const productRoutes = require('./routes/product.routes');

const app = express();

app.use(express.json());
app.use(morgan('dev'));

app.get('/api/v1/health', (req, res) => {
  res.json({ success: true, message: 'API is running' });
});

app.use('/api/v1/products', productRoutes);

module.exports = app;

Your API now handles all five CRUD operations through a properly layered architecture. Next up, you'll add optional image uploads so every product can have a photo hosted on Cloudinary's CDN.

Add Image Uploads with Cloudinary

Your product catalog now has full CRUD operations, but every product is just text data. Real product catalogs need images.

In this step, you'll integrate Cloudinary as your image hosting service and Multer as file upload middleware. Products will accept an optional image file that gets uploaded to the cloud, and deleting a product will automatically clean up its image.

In this step, get ready to:
  • Configure Cloudinary and create the upload middleware.
  • Add image fields to the Product model and update the service layer.
  • Test image upload and deletion in Postman.
Configure Cloudinary and create the upload middleware

Cloudinary stores and serves your images from a global CDN. Instead of saving files to your server's disk, Multer streams them directly to Cloudinary and returns a URL you store in MongoDB.

  • Create a new file at src/config/cloudinary.js and add the following configuration:
const cloudinary = require('cloudinary').v2;

// Configure Cloudinary with credentials from .env
cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET
});

module.exports = cloudinary;

What does this code do?

The require('cloudinary').v2 import uses the v2 sub-module, which is the safe universal import for both legacy and modern Cloudinary SDKs.

The cloudinary.config() call reads your three credential environment variables and authenticates every API call you make to Cloudinary.

  • Save src/config/cloudinary.js.
  • Create a new file at src/middleware/upload.js and add the upload middleware:
const multer = require('multer');
const { CloudinaryStorage } = require('multer-storage-cloudinary-v2');
const cloudinary = require('../config/cloudinary');

// Store uploads in a 'products' folder on Cloudinary
const storage = new CloudinaryStorage({
  cloudinary,
  params: {
    folder: 'products',
    allowed_formats: ['jpg', 'jpeg', 'png', 'webp']
  }
});

const upload = multer({ storage });

module.exports = upload;

What does this code do?

  • CloudinaryStorage is a Multer storage engine that streams uploaded files directly to Cloudinary instead of saving them to disk.
  • folder: 'products' organizes all product images into a single folder on your Cloudinary account.
  • allowed_formats restricts uploads to common image types. Any other file type is rejected automatically.
  • Save src/middleware/upload.js.

Seeing a Cannot find module error?

  • Confirm you installed multer-storage-cloudinary-v2 (not multer-storage-cloudinary). Check your package.json dependencies list.
  • Make sure your require() path in upload.js is '../config/cloudinary' (relative to the middleware folder).

Help me fix a module not found error in my upload middleware

Add image fields to the Product model

Your model needs two new fields: one to store the Cloudinary URL (for displaying the image) and one to store the public ID (for deleting the image later).

  • In src/models/product.model.js, find the createdAt field. Add these two fields directly above it:
createdAt: {
  • Add the image fields so the section now looks like this:
  // Cloudinary image URL (optional)
  imageUrl: {
    type: String,
    default: null
  },
  // Cloudinary public ID for deletion
  imagePublicId: {
    type: String,
    default: null
  },
  createdAt: {
    type: Date,
    default: Date.now
  }

Why store the public ID separately?

Cloudinary identifies every uploaded asset by a unique public_id. When you delete a product, you need this ID to tell Cloudinary to remove the image. The URL alone is not enough to trigger deletion through the API.

  • Save src/models/product.model.js.
Update routes, controller, and service for image upload

The route needs Multer middleware to process the file before the controller runs. The controller reads the uploaded file info from req.file and passes it to the service. The delete service destroys the Cloudinary image.

  • Open src/routes/product.routes.js. Find the imports at the top and add the upload middleware import below the existing ones:
const upload = require('../middleware/upload');
  • Find the POST route line:
router.post('/', createProduct);
  • Replace it with this version that processes an optional image field:
router.post('/', upload.single('image'), createProduct);
  • Find the PUT route line:
router.put('/:id', updateProduct);
  • Replace it with:
router.put('/:id', upload.single('image'), updateProduct);
  • Save src/routes/product.routes.js.

Now update the controller to pass image data to the service layer.

  • In src/controllers/product.controller.js, find the createProduct handler and replace it with this version:
// POST /products - create a new product (with optional image)
const createProduct = async (req, res, next) => {
  try {
    const data = { ...req.body };
    // Attach image info if a file was uploaded
    if (req.file) {
      data.imageUrl = req.file.path;
      data.imagePublicId = req.file.filename;
    }
    const product = await productService.createProduct(data);
    res.status(201).json({ success: true, data: product });
  } catch (error) {
    next(error);
  }
};
  • Find the updateProduct handler and replace it with:
// PUT /products/:id - update a product (with optional image)
const updateProduct = async (req, res, next) => {
  try {
    const data = { ...req.body };
    if (req.file) {
      data.imageUrl = req.file.path;
      data.imagePublicId = req.file.filename;
    }
    const product = await productService.updateProduct(
      req.params.id,
      data
    );
    res.json({ success: true, data: product });
  } catch (error) {
    next(error);
  }
};
  • Save src/controllers/product.controller.js.

Finally, update the delete service to destroy the Cloudinary image when a product is removed.

  • In src/services/product.service.js, add this import at the top, below the existing imports:
const cloudinary = require('../config/cloudinary');
  • Find the deleteProduct function and replace it with this version that cleans up the image:
const deleteProduct = async (id) => {
  if (!mongoose.isValidObjectId(id)) {
    throw new AppError('Invalid product ID format', 400);
  }
  const product = await Product.findByIdAndDelete(id);
  if (!product) {
    throw new AppError('Product not found', 404);
  }
  // Delete the image from Cloudinary if one exists
  if (product.imagePublicId) {
    await cloudinary.uploader.destroy(product.imagePublicId);
  }
  return product;
};

What does cloudinary.uploader.destroy do?

The destroy() method takes a public ID and permanently removes that asset from your Cloudinary account. Without this, deleted products would leave orphaned images consuming your storage quota.

  • Save src/services/product.service.js.
  • Stop your server with Ctrl+C and restart it by running:
node src/server.js
  • Confirm you see Server running on port 5000 and MongoDB connected with no errors.

Seeing a startup error?

  • Check that src/config/cloudinary.js exports the configured cloudinary instance (the last line should be module.exports = cloudinary;).
  • Confirm the import path in product.service.js is '../config/cloudinary'. The relative path must match your folder structure.

Help me debug a startup error after adding Cloudinary to my product service

Time to test. In Postman, you'll send a multipart form-data request instead of raw JSON to include a file.

  • Open Postman.
  • Create a new POST request to http://localhost:5000/api/v1/products.
  • Click the Body tab.
  • Select form-data (not raw/JSON).
  • Add these key-value pairs:
  • Key: name, Value: Wireless Headphones.
  • Key: price, Value: 79.99.
  • Key: category, Value: Electronics.
  • Key: image — change the type dropdown from Text to File, then select any image from your computer.
  • Click Send.

You should see a 201 Created response with the product data. The response now includes an imageUrl field containing a Cloudinary URL and an imagePublicId field.

  • Copy the imageUrl value from the response and paste it in a new browser tab.

You should see your uploaded image displayed in the browser, served from Cloudinary's CDN.

Now test that deleting a product also removes the image from Cloudinary.

  • Copy the _id from the product you just created.
  • Send a DELETE request to http://localhost:5000/api/v1/products/<your-product-id>.

You should get a 200 OK response. Refresh the browser tab with the image URL. It should now return a Cloudinary error page, confirming the image was destroyed.

Image URL still works after deletion?

  • Cloudinary may cache images briefly. Wait 30 seconds and try again.
  • Confirm your deleteProduct function has the if (product.imagePublicId) check and the await cloudinary.uploader.destroy() call.
  • Check your Cloudinary dashboard under Media Library > products folder. The image should no longer appear there.

Help me debug why Cloudinary images aren't being deleted when I remove a product

Your products now support optional image uploads hosted on Cloudinary's CDN. Deleting a product automatically cleans up its cloud image. Next up, you'll discover what happens when a client sends a bad request, and build centralized error handling to keep your API from crashing.

Add Centralized Error Handling

Your products now have images, full CRUD, and a clean layered architecture. But what happens when a client sends a product ID that doesn't exist, or one in a completely wrong format?

Right now, your API has no plan for failure. In this step, you'll hit that gap firsthand, then build a custom error class and centralized error handler that turns every failure into a clean, structured JSON response.

In this step, get ready to:
  • Observe how the API currently handles bad requests and see the problem.
  • Create a custom error class and update the service layer to throw meaningful errors.
  • Build centralized error handling middleware that returns consistent JSON for every failure.
Expose the error handling gap

Your API currently has no guards for invalid product IDs. Time to see what happens when things go wrong.

Before you run this, what do you think will happen if you request a product ID that doesn't exist in the database? Will you get a clean error, a crash, or something else?

  • Switch back to Postman.
  • Create a new GET request to this URL:
http://localhost:5000/api/v1/products/nonexistent123
  • Click Send.

You see an HTML error page with a stack trace and internal details like CastError spilling out. This is Express's default error handler dumping everything it knows to the client.

This ugly response is intentional

The error exposes your database driver name, file paths, and internal structure. A malicious user could use this information to probe your system. This is exactly the gap that centralized error handling solves.

To fix this, you need two things: a custom error class that carries a meaningful status code and message, and a middleware that intercepts all errors and formats them into clean JSON.

  • Create a new file src/middleware/appError.js and add the following code:
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

What does this code do?

  • AppError extends the built-in Error class so it works with try/catch and Express's next(error) pattern.
  • statusCode lets you attach the correct HTTP status (400, 404, 500) directly to the error.
  • isOperational distinguishes expected errors (bad user input) from unexpected bugs (null pointer). The error handler uses this flag to decide whether to expose the message or hide it.
  • Error.captureStackTrace cleans up the stack trace so it points to where the error was thrown, not inside the AppError class itself.
  • Save src/middleware/appError.js.
Update the service to throw custom errors

Your service functions currently return null when a product isn't found, and let Mongoose throw raw errors for malformed IDs. Now you'll make the service layer responsible for validating IDs and throwing AppError instances with the correct status codes.

  • In src/services/product.service.js, find the imports at the top of the file:
const mongoose = require('mongoose');
const Product = require('../models/product.model');
  • Add the AppError import directly below, so the imports now look like this:
const mongoose = require('mongoose');
const Product = require('../models/product.model');
const AppError = require('../middleware/appError');
  • Find the getProductById function and replace it with this version that validates the ID format and checks whether the product exists:
const getProductById = async (id) => {
  // Reject IDs that aren't valid MongoDB ObjectIds
  if (!mongoose.isValidObjectId(id)) {
    throw new AppError('Invalid product ID format', 400);
  }
  const product = await Product.findById(id);
  // Throw a 404 if no product matches the valid ID
  if (!product) {
    throw new AppError('Product not found', 404);
  }
  return product;
};

What does this code do?

mongoose.isValidObjectId(id) checks whether the string matches MongoDB's 24-character hex format. If it doesn't, the function throws immediately with a 400 status instead of letting Mongoose crash with a CastError.

If the ID format is valid but no document matches, the function throws a 404. The controller's existing try/catch catches both and passes them to next(error).

  • Now replace the updateProduct and deleteProduct functions with these versions that apply the same validation pattern:
const updateProduct = async (id, data) => {
  if (!mongoose.isValidObjectId(id)) {
    throw new AppError('Invalid product ID format', 400);
  }
  const product = await Product.findByIdAndUpdate(id, data, {
    new: true,
    runValidators: true
  });
  if (!product) {
    throw new AppError('Product not found', 404);
  }
  return product;
};

const deleteProduct = async (id) => {
  if (!mongoose.isValidObjectId(id)) {
    throw new AppError('Invalid product ID format', 400);
  }
  const product = await Product.findByIdAndDelete(id);
  if (!product) {
    throw new AppError('Product not found', 404);
  }
  return product;
};

Why the same pattern in all three functions?

Every function that accepts an ID from the client needs the same two guards: is the format valid, and does a document actually exist? Repeating this pattern ensures no function accidentally passes an invalid ID to Mongoose.

  • Save src/services/product.service.js.
  • Stop your running server with Ctrl+C and restart it by running:
node src/server.js
  • Confirm you see Server running on port 5000 and MongoDB connected with no errors. This proves your updated service has no syntax issues.

Seeing a syntax error on startup?

  • Check that the AppError import path is exactly '../middleware/appError'. The file name is case-sensitive.
  • Confirm each function still has its closing }; and the module.exports block at the bottom is intact.
  • Help me debug a syntax error in my product service file.
Create the centralized error handler

Your service now throws meaningful errors, but Express's default handler still catches them and sends ugly HTML. You need a custom error-handling middleware that intercepts these errors and formats them into consistent JSON.

  • Create a new file src/middleware/errorHandler.js and add the following code:
const errorHandler = (err, req, res, next) => {
  // Use the error's status code, or default to 500
  const statusCode = err.statusCode || 500;
  // Only expose the message for operational errors
  const message = err.isOperational ? err.message : 'Internal server error';

  // Log the full stack trace in development for debugging
  if (process.env.NODE_ENV !== 'production') {
    console.error(err.stack);
  }

  res.status(statusCode).json({
    success: false,
    message
  });
};

module.exports = errorHandler;

What does this code do?

Express recognizes this as an error-handling middleware because it has four parameters: err, req, res, next. Any error passed to next(error) lands here.

  • For operational errors (ones you threw intentionally with AppError), it returns the specific message and status code.
  • For unexpected bugs (where isOperational is false or undefined), it hides the details behind a generic "Internal server error" message. No stack traces reach the client.
  • Save src/middleware/errorHandler.js.

Now you need to wire the error handler into your Express app. It must be registered as the last middleware, after all routes, so it catches errors from every endpoint.

  • In src/app.js, find the imports at the top of the file. Add this line below the productRoutes import:
const errorHandler = require('./middleware/errorHandler');
  • Scroll to the bottom of src/app.js. Find this line:
app.use('/api/v1/products', productRoutes);
  • Add the error handler directly below it, so the end of the file looks like this:
app.use('/api/v1/products', productRoutes);

app.use(errorHandler);

module.exports = app;

Why does it go last?

Express processes middleware in order. The error handler must sit below all routes so that any next(error) call from your controllers flows down to it. If it were above the routes, errors would skip it entirely.

  • Save src/app.js.
  • Stop your server with Ctrl+C and restart it by running:
node src/server.js

Before you test, predict: what do you expect to see when you request that same invalid product ID now? Will it still be ugly HTML, or something different?

  • Switch back to Postman. Send the same GET request to:
http://localhost:5000/api/v1/products/nonexistent123
  • Click Send.

You now see a clean JSON response with status 400:

{
  "success": false,
  "message": "Invalid product ID format"
}

The status code is 400 because nonexistent123 is not a valid MongoDB ObjectId format. Now test with a valid-format ID that doesn't match any product.

  • Send a GET request to this URL (a valid ObjectId format that won't match any product):
http://localhost:5000/api/v1/products/507f1f77bcf86cd799439011

You see a 404 response:

{
  "success": false,
  "message": "Product not found"
}

No stack traces. No internal details. Just a clean, consistent error response that tells the client exactly what went wrong.

Still seeing HTML errors or a 500 status?

  • Make sure app.use(errorHandler) is below app.use('/api/v1/products', productRoutes) in src/app.js. Order matters.
  • Confirm you restarted the server after saving both files. Changes don't take effect until you restart.
  • Check that your errorHandler function has exactly four parameters: (err, req, res, next). Express only recognizes it as an error handler with all four.
  • Help me debug why my error handler isn't returning clean JSON.

✔️ Awesome, I've got everything!

Great. Double check you've saved both src/middleware/errorHandler.js and src/app.js.

ⓧ I'd like to double check the full code

Here is src/app.js as it should look now:

require('dotenv').config();
const express = require('express');
const morgan = require('morgan');
const productRoutes = require('./routes/product.routes');
const errorHandler = require('./middleware/errorHandler');

const app = express();

app.use(express.json());
app.use(morgan('dev'));

app.get('/api/v1/health', (req, res) => {
  res.json({ success: true, message: 'API is running' });
});

app.use('/api/v1/products', productRoutes);

app.use(errorHandler);

module.exports = app;

Here is src/middleware/appError.js:

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

Here is src/middleware/errorHandler.js:

const errorHandler = (err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const message = err.isOperational ? err.message : 'Internal server error';

  if (process.env.NODE_ENV !== 'production') {
    console.error(err.stack);
  }

  res.status(statusCode).json({
    success: false,
    message
  });
};

module.exports = errorHandler;

Here is src/services/product.service.js:

const mongoose = require('mongoose');
const Product = require('../models/product.model');
const AppError = require('../middleware/appError');

const createProduct = async (data) => {
  const product = await Product.create(data);
  return product;
};

const getAllProducts = async () => {
  const products = await Product.find();
  return products;
};
const getProductById = async (id) => {
  if (!mongoose.isValidObjectId(id)) {
    throw new AppError('Invalid product ID format', 400);
  }
  const product = await Product.findById(id);
  if (!product) {
    throw new AppError('Product not found', 404);
  }
  return product;
};

const updateProduct = async (id, data) => {
  if (!mongoose.isValidObjectId(id)) {
    throw new AppError('Invalid product ID format', 400);
  }
  const product = await Product.findByIdAndUpdate(id, data, {
    new: true,
    runValidators: true
  });
  if (!product) {
    throw new AppError('Product not found', 404);
  }
  return product;
};
const deleteProduct = async (id) => {
  if (!mongoose.isValidObjectId(id)) {
    throw new AppError('Invalid product ID format', 400);
  }
  const product = await Product.findByIdAndDelete(id);
  if (!product) {
    throw new AppError('Product not found', 404);
  }
  return product;
};

module.exports = {
  createProduct,
  getAllProducts,
  getProductById,
  updateProduct,
  deleteProduct
};

Your API now handles failures gracefully. Every bad request gets a consistent JSON response with the right status code. Next up, you'll add request validation that catches invalid data before it even reaches the service layer.

Add Request Validation

Your centralized error handler now catches problems gracefully. But bad data still travels through the controller and service layer before Mongoose rejects it at the database level.

Validation middleware stops invalid requests at the door. Instead of letting a payload with a missing name or a negative price reach your database, validation rejects it immediately with specific, field-level error messages.

In this step, get ready to:
  • Create validation schemas that define rules for each field in a product.
  • Build a reusable validate middleware that formats and returns validation errors.
  • Wire validation into your routes so bad payloads are rejected before reaching the service layer.
Create validation schemas for product data

The express-validator library provides chain methods like body() and param() that let you declare rules for each field in a request. You will define three validators: one for creating products, one for updating, and one for validating ID parameters.

  • Create a new folder inside src/middleware/ called validators by running this command:
mkdir src/middleware/validators
  • Create a new file called product.validator.js inside src/middleware/validators/ and add the createProductValidator:
const { body, param } = require('express-validator');

// Rules for creating a new product - all required fields must be present
const createProductValidator = [
  body('name')
    .trim()
    .notEmpty().withMessage('Name is required')
    .isLength({ min: 3, max: 100 }).withMessage('Name must be between 3 and 100 characters'),
  body('price')
    .notEmpty().withMessage('Price is required')
    .isFloat({ gt: 0 }).withMessage('Price must be a positive number'),
  body('category')
    .trim()
    .notEmpty().withMessage('Category is required'),
  body('description')
    .optional()
    .trim()
    .isLength({ max: 500 }).withMessage('Description cannot exceed 500 characters')
];

What does this code do?

Each body() call targets a specific JSON field from the request body and chains validation rules onto it.

  • .trim() removes whitespace from both ends of a string before checking it.
  • .notEmpty() rejects empty strings. .isFloat({ gt: 0 }) ensures price is a positive number.
  • .optional() on description means the field can be omitted entirely, but if present, it must pass the length check.
  • Below the createProductValidator array, add the update validator and the ID param validator:
// Rules for updating - all fields optional, but validated if present
const updateProductValidator = [
  body('name')
    .optional()
    .trim()
    .isLength({ min: 3, max: 100 }).withMessage('Name must be between 3 and 100 characters'),
  body('price')
    .optional()
    .isFloat({ gt: 0 }).withMessage('Price must be a positive number'),
  body('category')
    .optional()
    .trim()
    .notEmpty().withMessage('Category cannot be empty'),
  body('description')
    .optional()
    .trim()
    .isLength({ max: 500 }).withMessage('Description cannot exceed 500 characters')
];

// Validates that the :id URL param is a valid MongoDB ObjectId
const idParamValidator = [
  param('id').isMongoId().withMessage('Invalid product ID format')
];

module.exports = {
  createProductValidator,
  updateProductValidator,
  idParamValidator
};

Why is the update validator different?

When updating a product, the client might send only the fields they want to change. Making every field .optional() allows partial updates while still enforcing rules on any field that IS present.

The idParamValidator uses param('id').isMongoId() to catch malformed IDs before they even reach your service layer.

  • Save src/middleware/validators/product.validator.js.
  • Confirm the new folder and file exist by running:
ls src/middleware/validators/

You should see product.validator.js listed in the output.

Don't see the file?

  • Make sure you created the validators folder inside src/middleware/, not in the project root.
  • Check that you saved the file after pasting both code chunks.

Why isn't my validator file showing up?

Create the reusable validate middleware

The validators you just wrote declare rules, but they do not reject the request on their own. You need a separate middleware that checks whether any rules failed and sends back the error response.

  • Create a new file called validate.js inside src/middleware/ and add this code:
const { validationResult } = require('express-validator');

// Checks if any validation rules failed and returns errors or continues
const validate = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({
      success: false,
      errors: errors.array().map(err => ({
        field: err.path,
        message: err.msg
      }))
    });
  }
  next();
};

module.exports = validate;

What does this code do?

  • validationResult(req) collects all the errors that the validator chain found on the request.
  • If there are errors, it maps them into a clean {field, message} format and returns a 400 response. The client gets a structured array telling them exactly which fields failed and why.
  • If no errors exist, next() passes the request along to the controller.
  • Save src/middleware/validate.js.

Reusable across your whole app

This validate middleware is generic. You can reuse it with any set of validator rules for any route in any future resource (users, orders, reviews). Pair it with a new validator array and you are done.

Wire validation into the routes and test

The validator schemas and the validate middleware exist, but nothing uses them yet. You need to insert them into the route definitions so they run before each controller function.

  • Open src/routes/product.routes.js and replace its entire contents with the following:
const express = require('express');
const router = express.Router();
const {
  createProduct,
  getAllProducts,
  getProductById,
  updateProduct,
  deleteProduct
} = require('../controllers/product.controller');
// Import the validator schemas and the validate middleware
const { createProductValidator, updateProductValidator, idParamValidator } = require('../middleware/validators/product.validator');
const validate = require('../middleware/validate');

router.post('/', createProductValidator, validate, createProduct);
router.get('/', getAllProducts);
router.get('/:id', idParamValidator, validate, getProductById);
router.put('/:id', idParamValidator, updateProductValidator, validate, updateProduct);
router.delete('/:id', idParamValidator, validate, deleteProduct);

module.exports = router;

How does the middleware chain work?

Express executes middleware in the order you list them. For the POST route, the request flows through createProductValidator (checks the rules), then validate (rejects or passes), then finally createProduct.

The GET all route (/) has no validation because listing products requires no input from the client.

  • Save src/routes/product.routes.js.
  • Restart your server by pressing Ctrl+C in the terminal, then running:
node src/server.js

You should see "Server running on port 5000" and "MongoDB connected" again.

Now test the validation with an intentionally invalid payload. Before you send this request, what do you think will happen when name is empty and price is negative?

  • In Postman, send a POST request to http://localhost:5000/api/v1/products with this JSON body:
{
  "name": "",
  "price": -5
}

You should receive a 400 response with an errors array listing each field that failed validation:

{
  "success": false,
  "errors": [
    { "field": "name", "message": "Name is required" },
    { "field": "price", "message": "Price must be a positive number" },
    { "field": "category", "message": "Category is required" }
  ]
}

Why three errors and not two?

You sent an empty name and a negative price, but you also did not include a category field at all. The validator catches all three problems in one pass and returns them together. The client can fix everything in one go instead of playing whack-a-mole with one error at a time.

  • Test the ID param validation by sending a GET request to http://localhost:5000/api/v1/products/not-a-valid-id.

You should get a 400 response with the error: "Invalid product ID format". The request never reaches the service layer.

Still seeing a 500 error or a Mongoose error?

  • Make sure you saved all three files: product.validator.js, validate.js, and product.routes.js.
  • Check that the import path in product.routes.js matches your folder structure exactly: ../middleware/validators/product.validator.
  • Confirm you restarted the server after saving. Node.js does not hot-reload file changes.

Why am I still seeing Mongoose errors instead of validation errors?

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the full src/middleware/validators/product.validator.js:

const { body, param } = require('express-validator');

const createProductValidator = [
  body('name')
    .trim()
    .notEmpty().withMessage('Name is required')
    .isLength({ min: 3, max: 100 }).withMessage('Name must be between 3 and 100 characters'),
  body('price')
    .notEmpty().withMessage('Price is required')
    .isFloat({ gt: 0 }).withMessage('Price must be a positive number'),
  body('category')
    .trim()
    .notEmpty().withMessage('Category is required'),
  body('description')
    .optional()
    .trim()
    .isLength({ max: 500 }).withMessage('Description cannot exceed 500 characters')
];
const updateProductValidator = [
  body('name')
    .optional()
    .trim()
    .isLength({ min: 3, max: 100 }).withMessage('Name must be between 3 and 100 characters'),
  body('price')
    .optional()
    .isFloat({ gt: 0 }).withMessage('Price must be a positive number'),
  body('category')
    .optional()
    .trim()
    .notEmpty().withMessage('Category cannot be empty'),
  body('description')
    .optional()
    .trim()
    .isLength({ max: 500 }).withMessage('Description cannot exceed 500 characters')
];

const idParamValidator = [
  param('id').isMongoId().withMessage('Invalid product ID format')
];

module.exports = {
  createProductValidator,
  updateProductValidator,
  idParamValidator
};

Here is the full src/middleware/validate.js:

const { validationResult } = require('express-validator');

const validate = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({
      success: false,
      errors: errors.array().map(err => ({
        field: err.path,
        message: err.msg
      }))
    });
  }
  next();
};

module.exports = validate;

Here is the full src/routes/product.routes.js:

const express = require('express');
const router = express.Router();
const {
  createProduct,
  getAllProducts,
  getProductById,
  updateProduct,
  deleteProduct
} = require('../controllers/product.controller');
const { createProductValidator, updateProductValidator, idParamValidator } = require('../middleware/validators/product.validator');
const validate = require('../middleware/validate');

router.post('/', createProductValidator, validate, createProduct);
router.get('/', getAllProducts);
router.get('/:id', idParamValidator, validate, getProductById);
router.put('/:id', idParamValidator, updateProductValidator, validate, updateProduct);
router.delete('/:id', idParamValidator, validate, deleteProduct);

module.exports = router;

Your API now rejects bad data at the door with specific, helpful error messages. Next up, you will harden the API with security headers, CORS, and rate limiting to protect it from abuse.

Harden the API with Security Middleware

Your API validates input and handles errors gracefully. But it has no protection against common web attacks or abuse.

Anyone on the internet can flood your endpoints with unlimited requests, and your responses are missing the security headers that browsers and infrastructure tools expect. In this step, you'll add the production security layer that every deployed API needs.

In this step, get ready to:
  • Add helmet and CORS middleware for security headers and cross-origin control.
  • Add rate limiting to prevent request flooding.
  • Test the full security stack and verify protection is active.
Add helmet and CORS middleware

Right now, your API responses contain no security headers. Browsers use these headers to decide whether to trust a response. Helmet automatically sets headers like X-Content-Type-Options and Strict-Transport-Security that protect against common attacks.

  • In src/app.js, replace the require statements at the top of the file (everything above const app = express();) with these updated imports:
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const { rateLimit } = require('express-rate-limit');
const productRoutes = require('./routes/product.routes');
const errorHandler = require('./middleware/errorHandler');

What are these new imports?

  • helmet sets secure HTTP headers on every response with zero configuration.
  • cors controls which origins (domains) are allowed to call your API.
  • { rateLimit } is a named export from express-rate-limit that limits repeated requests from a single IP.
  • Save src/app.js.

Now add the middleware that uses these imports. The order matters here.

  • In src/app.js, find the line app.use(express.json()); and paste these lines directly above it:
// Set security HTTP headers
app.use(helmet());
// Configure cross-origin resource sharing
app.use(cors({
  origin: process.env.CORS_ORIGIN || '*'
}));

What does this code do?

app.use(helmet()) adds 13 security headers to every response automatically. It removes the X-Powered-By header (which advertises your tech stack) and adds protections like X-Content-Type-Options and X-Frame-Options.

app.use(cors({...})) reads CORS_ORIGIN from your .env file. The * fallback allows any origin in development. In production, you would set this to your frontend's domain.

These middlewares are placed before express.json() so security headers and CORS are applied even if JSON parsing fails.

  • Save src/app.js.
  • Stop your running server with Ctrl+C, then restart it by running:
node src/server.js
  • In Postman, send a GET request to http://localhost:5000/api/v1/health.
  • Click the Headers tab in the response section.

You should see new security headers including x-content-type-options: nosniff and strict-transport-security. The x-powered-by header is gone.

Not seeing security headers?

  • Make sure you restarted the server after saving. Changes to app.js require a restart.
  • Check that app.use(helmet()) appears above app.use(express.json()) in your file. Middleware order matters.
  • Confirm you are looking at the Headers tab of the response, not the request.

Why aren't my helmet security headers showing in Postman?

Add rate limiting middleware

Security headers protect against browser-based attacks, but they do nothing against someone flooding your API with thousands of requests. Rate limiting caps how many requests a single IP can make in a time window.

  • In src/app.js, find the app.use(cors({...})); block you just added and paste this rate limiter directly below it (still above app.use(express.json())):
// Rate limit: max requests per IP within a time window
app.use(rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  handler: (req, res) => {
    res.status(429).json({
      success: false,
      message: 'Too many requests, please try again later'
    });
  }
}));

What does this code do?

  • windowMs: 15 * 60 * 1000 sets a 15-minute sliding window for counting requests.
  • max: 100 allows up to 100 requests per IP in that window.
  • standardHeaders: true sends standard RateLimit-* headers so clients know their limit status.
  • legacyHeaders: false disables the older X-RateLimit-* format.
  • handler sends a custom JSON response matching your error format when the limit is exceeded.

To test the rate limiter without sending 101 requests, you will temporarily lower the limit.

  • In the rate limiter code you just added, find the line max: 100, and change it to:
max: 5,
  • Save src/app.js.
  • Stop your server with Ctrl+C and restart it by running:
node src/server.js

Your server is now running with all three security layers active and a low rate limit for easy testing.

Seeing a syntax error on startup?

  • Check that the rateLimit({...}) block has matching opening and closing parentheses and braces. The nested handler function adds extra nesting.
  • Make sure the handler property ends with a comma if followed by another property, and does not have a trailing comma after the last } before ));.

Help me fix a syntax error in my Express rate limiter middleware configuration.

Test the full security stack

All three security layers are active. Time to prove each one works.

  • In Postman, send a GET request to http://localhost:5000/api/v1/health.
  • In the response Headers tab, confirm you see x-content-type-options: nosniff (helmet) and access-control-allow-origin: * (CORS).

What are these CORS headers?

The access-control-allow-origin header tells browsers which domains are allowed to call your API. The * value means any origin is allowed. In production, you would restrict this to your frontend's domain only.

Now test rate limiting. Before you send more requests, what do you think will happen when you send a 6th request to the same endpoint?

  • In Postman, send the same GET request to http://localhost:5000/api/v1/health five more times rapidly (you already sent one above, so this makes six total).

On the 6th request, you get a 429 status code with this response body:

{
  "success": false,
  "message": "Too many requests, please try again later"
}

The rate limiter blocked the request because your IP exceeded 5 requests in the 15-minute window. In production with max: 100, this threshold is generous enough for normal use but still prevents abuse.

  • In src/app.js, find max: 5, in the rate limiter and change it back to:
max: 100,
  • Save src/app.js.
  • Stop your server with Ctrl+C and restart by running:
node src/server.js
  • Send one more GET request to http://localhost:5000/api/v1/health in Postman to confirm your API responds normally again with a 200 status.

Still getting 429 after restarting?

  • Make sure you saved the file and restarted the server. The rate limiter resets its counters on restart.
  • Double-check that max is set to 100, not 5.

Why am I still getting rate limited after restarting my Express server?

✔️ Awesome, I've got everything!

Great. Double-check you saved src/app.js with max: 100 restored and your server is running.

ⓧ I'd like to double check the full code

Your src/app.js should contain these sections in order. Scroll up and confirm each one is present:

  • 8 require statements at the top (dotenv, express, helmet, cors, morgan, rateLimit, productRoutes, errorHandler).
  • app.use(helmet()) as the first middleware after const app = express().
  • app.use(cors({...})) with process.env.CORS_ORIGIN || '*'.
  • app.use(rateLimit({...})) with max: 100, standardHeaders: true, legacyHeaders: false, and a custom handler function.
  • app.use(express.json()) and app.use(morgan('dev')) after the security middleware.
  • The health check route, product routes, and error handler unchanged at the bottom.

Your API is now production-hardened with security headers, cross-origin controls, and abuse prevention. Next up, you'll take on the Secret Mission and add filtering, sorting, and pagination to make your catalog endpoint truly production-ready.

Secret mission

Add Filtering, Sorting, and Pagination

Your GET /products endpoint returns every product in the database. A real catalog with thousands of items needs query capabilities. Can you upgrade it to support filtering by category, sorting by any field, and paginated results with metadata?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs on MongoDB Atlas's free tier, Cloudinary's free tier, and local files, so there are no ongoing costs.

Resources you used:

  • MongoDB Atlas cluster (free tier M0, no cost).
  • Cloudinary account (free tier, images stored in the products folder).
  • Local Node.js project files on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or plan to continue with the next project in this series (adding authentication to this API).

  • Your Atlas M0 cluster stays on the free tier indefinitely with no charges.
  • Your Cloudinary free tier includes 25GB of storage. Product images from this project use negligible space.
  • Your local project folder is ready for the next project.
  • Your server will keep running until you close the terminal or press Ctrl+C.

Pause - I'll come back to this later

Shut down running processes to free up your terminal. Your files and Atlas data stay intact for when you return.

  • In the terminal where your server is running, press Ctrl+C to stop the Node.js server.
  • Your Atlas free tier cluster has no idle charges. It stays available whenever you reconnect.
  • Your project files remain on your Desktop, ready to pick up again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your product data and local files permanently.

  • Stop your server by pressing Ctrl+C in the terminal.
  • Delete uploaded images from Cloudinary: log in to your Cloudinary dashboard, click Media Library, open the products folder, select all images, and click Delete.
  • Drop your product data in MongoDB Atlas: log in to your Atlas account, click your cluster name, click Browse Collections, find the products collection, click the trash icon next to it, and confirm the drop.
  • Delete your local project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf product-catalog-api
  • On Windows, run:
cd ~\Desktop
rmdir /s /q product-catalog-api

Replace product-catalog-api with the actual name of your project folder if you chose a different name.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-structured REST API from the ground up, complete with layered architecture, cloud image uploads, centralized error handling, input validation, and a full security stack.

You've learned how to:

  • Build a REST API with proper layered architecture and cloud image uploads using Cloudinary, Multer, and MongoDB.
  • Implement centralized error handling with custom error classes and input validation middleware that reject bad payloads before they reach the database.
  • Harden the API with helmet for security headers, CORS for cross-origin control, and rate limiting for abuse prevention.
  • Secret Mission: Added filtering, sorting, and pagination to transform a basic endpoint into a production-ready query interface.

Ready to quiz yourself?