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 product02 — 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