SkillShare Logo
STEP 5 OF 7 · FEATURES

Product CRUD

The heart of the app: Create, Read, Update, and Delete products, plus a stock dashboard. Every route sits behind the auth gate you just built.

01 — THE API

The Five Endpoints

A standard REST resource (the standard way to expose create/read/update/delete over HTTP). All of these require a valid Bearer token (the protect middleware / JwtAuthGuard from the last page).

REST reference
POST   /api/products        # create a product
GET    /api/products        # list all (the stock view)
GET    /api/products/:id    # read one
PUT    /api/products/:id    # update details / restock
DELETE /api/products/:id    # remove a product
02 — VALIDATION

Validate the Input

Input validation guards create and update endpoints so bad data never reaches the database.

server/src/routes/products.js — validation rules
import { body } from 'express-validator'

// Use these validators in your route definition:
export const productValidation = [
  body('name').trim().notEmpty().withMessage('Product name is required'),
  body('sku').trim().notEmpty().withMessage('SKU is required'),
  body('category').trim().notEmpty().withMessage('Category is required'),
  body('price').isFloat({ min: 0 }).withMessage('Price must be a positive number'),
  body('quantity').isInt({ min: 0 }).withMessage('Quantity must be a non-negative integer'),
  body('minStockLevel').isInt({ min: 0 }).withMessage('Min stock level must be non-negative'),
]
// Applied in router: router.post('/', productValidation, createProduct)
03 — BACKEND

CRUD Handlers

Database calls are user-scoped. All routes sit behind the protect() middleware / JwtAuthGuard so request context is verified for the logged-in user.

server/src/controllers/productController.js
import { validationResult } from 'express-validator'
import Product from '../models/Product.js'
import { sendLowStockAlert } from '../services/emailService.js'

const triggerLowStockAlert = (product) => {
  if (product.quantity <= product.minStockLevel) {
    sendLowStockAlert(product).catch((err) =>
      console.error('Failed to send low-stock alert:', err.message)
    )
  }
}

// GET all — scoped to the logged-in user
export const getProducts = async (req, res, next) => {
  try {
    const { search, category } = req.query
    const filter = { createdBy: req.user._id }
    if (search) {
      filter.$or = [
        { name: { $regex: search, $options: 'i' } },
        { sku: { $regex: search, $options: 'i' } },
        { category: { $regex: search, $options: 'i' } },
      ]
    }
    if (category) filter.category = { $regex: `^${category}$`, $options: 'i' }
    const products = await Product.find(filter).sort({ createdAt: -1 })
    res.status(200).json({ products })
  } catch (err) { next(err) }
}

// POST create
export const createProduct = async (req, res, next) => {
  try {
    const errors = validationResult(req)
    if (!errors.isEmpty()) return res.status(400).json({ message: 'Validation failed', errors: errors.mapped() })
    const { name, description, sku, category, price, quantity, minStockLevel } = req.body
    const product = await Product.create({ name, description, sku, category, price, quantity, minStockLevel, createdBy: req.user._id })
    triggerLowStockAlert(product)
    res.status(201).json({ product, message: 'Product created successfully' })
  } catch (err) { next(err) }
}

// PUT update
export const updateProduct = async (req, res, next) => {
  try {
    const errors = validationResult(req)
    if (!errors.isEmpty()) return res.status(400).json({ message: 'Validation failed', errors: errors.mapped() })
    const product = await Product.findOneAndUpdate(
      { _id: req.params.id, createdBy: req.user._id },
      { $set: req.body },
      { new: true, runValidators: true }
    )
    if (!product) return res.status(404).json({ message: 'Product not found' })
    triggerLowStockAlert(product)
    res.status(200).json({ product, message: 'Product updated successfully' })
  } catch (err) { next(err) }
}

// DELETE
export const deleteProduct = async (req, res, next) => {
  try {
    const product = await Product.findOneAndDelete({ _id: req.params.id, createdBy: req.user._id })
    if (!product) return res.status(404).json({ message: 'Product not found' })
    res.status(200).json({ message: 'Product deleted successfully' })
  } catch (err) { next(err) }
}

// Routes in server/src/routes/products.js:
// router.use(protect)   ← all product routes require auth
// router.get('/', getProducts)
// router.post('/', productValidation, createProduct)
// router.put('/:id', productValidation, updateProduct)
// router.delete('/:id', deleteProduct)
04 — FRONTEND

The Stock Dashboard

Fetch the list, show it in a table, and add a form to create products. Remember to attach the Bearer token in the Authorization header on every request.

web/app/products/page.tsx ("use client")
"use client";
import { useEffect, useState } from "react";

const API = "http://localhost:5000/api";
const get = (p: string) => {
  const token = localStorage.getItem("token");
  return fetch(API + p, {
    headers: { "Authorization": `Bearer ${token}` },
  }).then(r => r.json());
};

export default function Products() {
  const [items, setItems] = useState<any[]>([]);
  useEffect(() => { get("/products").then(({ products }) => setItems(products)); }, []);

  return (
    <table className="w-full text-left">
      <thead><tr><th>Name</th><th>SKU</th><th>Category</th><th>Qty</th><th>Min</th></tr></thead>
      <tbody>
        {items.map(p => (
          <tr key={p.id ?? p._id} className={p.quantity <= p.minStockLevel ? "text-red-500" : ""}>
            <td>{p.name}</td><td>{p.sku}</td><td>{p.category}</td>
            <td>{p.quantity}</td><td>{p.minStockLevel}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
add a product
async function addProduct(body: object) {
  const token = localStorage.getItem("token");
  await fetch("http://localhost:5000/api/products", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${token}`,
    },
    body: JSON.stringify(body),
  });
}
  • Create returns 201 with product object in the response
  • Update (PUT) changes the values; low-stock alert triggers if quantity ≤ minStockLevel
  • Delete returns 200 (product was found) or 404 (not yours/not found)
  • Low-stock rows render in red when quantity ≤ minStockLevel