Build a Complete CRUD REST API with Express.js and MySQL

A database-backed REST API is the foundation of many modern web applications, mobile apps, dashboards, ERP systems, HRMS platforms, inventory applications, and e-commerce…

What we do
  • WordPress Themes & Plugins
  • E-commerce, News Portals, Web Apps
  • Mobile & Desktop Applications
  • Professional IT Training Center
Published: 2026-07-18 19:59:27

Article

A database-backed REST API is the foundation of many modern web applications, mobile apps, dashboards, ERP systems, HRMS platforms, inventory applications, and e-commerce solutions. In this tutorial, you will build a complete CRUD REST API using Node.js, Express.js, MySQL, and the MySQL2 package.

What You Will Build: A product management REST API that can create, retrieve, update, partially update, search, paginate, and delete products stored in a MySQL database.

This tutorial continues from our Building REST APIs with Express.js guide. The earlier article used temporary in-memory data, while this tutorial stores information permanently in MySQL.

Technologies Used

Technology Purpose
Node.js Runs JavaScript on the server
Express.js Handles routes, middleware, requests, and responses
MySQL Stores product information permanently
MySQL2 Connects the Node.js application to MySQL
dotenv Loads environment variables from a configuration file
Nodemon Automatically restarts the server during development

What Does CRUD Mean?

CRUD represents the four main operations performed on application data.

CRUD Operation HTTP Method Example Endpoint
Create POST /api/v1/products
Read GET /api/v1/products
Update PUT PATCH /api/v1/products/:id
Delete DELETE /api/v1/products/:id

For a detailed explanation of HTTP methods, read Express.js Routing Explained: GET, POST, PUT & DELETE Requests.

Prerequisites

Before beginning, make sure you have:

  • Node.js installed
  • npm installed
  • MySQL or MariaDB installed
  • A code editor such as Visual Studio Code
  • Postman, Insomnia, or another API-testing tool
  • Basic JavaScript and Express.js knowledge

Check the installed Node.js and npm versions:

node -v


npm -v

To manage multiple Node.js versions, read How to Use NVM (Node Version Manager) Like a Pro.

Step 1: Create the Project

Open a terminal and run:

mkdir express-mysql-crud-api


cd express-mysql-crud-api
npm init -y

The npm init -y command creates the project's package.json file.

New to package management? Read What Is NPM? Complete Guide for Beginners.

Step 2: Install the Dependencies

npm install express mysql2 dotenv

Install Nodemon as a development dependency:

npm install --save-dev nodemon

Update the scripts section in package.json:

{
"scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
}


}

Step 3: Create the MySQL Database

Open phpMyAdmin, MySQL Workbench, or the MySQL command line and run:

CREATE DATABASE express_crud_api


CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;

Select the database:

USE express_crud_api;

Step 4: Create the Products Table

CREATE TABLE products (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(150) NOT NULL,
sku VARCHAR(100) NOT NULL,
description TEXT NULL,
price DECIMAL(12, 3) NOT NULL DEFAULT 0.000,
stock INT UNSIGNED NOT NULL DEFAULT 0,
status ENUM('active', 'inactive') NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
    ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_products_sku (sku),
KEY idx_products_name (name),
KEY idx_products_status (status),
KEY idx_products_created_at (created_at)


) ENGINE=InnoDB;
The table uses a unique SKU, a three-decimal price, status values, timestamps, and indexes for commonly searched columns.

Step 5: Insert Sample Data

INSERT INTO products
(name, sku, description, price, stock, status)


VALUES
(
'Business Laptop',
'LAP-001',
'Laptop for office and business use',
325.000,
10,
'active'
),
(
'Wireless Mouse',
'MOU-001',
'Wireless optical mouse',
8.500,
30,
'active'
),
(
'Mechanical Keyboard',
'KEY-001',
'Mechanical keyboard with USB connection',
22.750,
15,
'active'
);

Step 6: Create the Project Structure

express-mysql-crud-api/


│
├── src/
│   ├── config/
│   │   └── database.js
│   ├── controllers/
│   │   └── productController.js
│   ├── middleware/
│   │   ├── asyncHandler.js
│   │   ├── errorHandler.js
│   │   └── notFound.js
│   ├── models/
│   │   └── productModel.js
│   ├── routes/
│   │   └── productRoutes.js
│   ├── services/
│   │   └── productService.js
│   ├── utils/
│   │   └── AppError.js
│   └── app.js
│
├── .env
├── .env.example
├── .gitignore
├── package.json
└── server.js
Why Use Separate Folders?
  • Routes define endpoint URLs.
  • Controllers handle HTTP requests and responses.
  • Services contain business logic.
  • Models communicate with the database.
  • Middleware handles shared request-processing logic.
  • Config stores database and application configuration.

Step 7: Configure Environment Variables

Create a .env file in the project root:

NODE_ENV=development


PORT=3000

DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=express_crud_api
DB_USER=root
DB_PASSWORD=
DB_CONNECTION_LIMIT=10
Never upload your real .env file to a public Git repository. It may contain database passwords, API keys, and other confidential values.

Create an example file named .env.example:

NODE_ENV=development


PORT=3000

DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=database_name
DB_USER=database_user
DB_PASSWORD=database_password
DB_CONNECTION_LIMIT=10

Step 8: Create the .gitignore File

node_modules/


.env
npm-debug.log*
coverage/
dist/
.DS_Store

Step 9: Create the MySQL Connection Pool

Create src/config/database.js:

const mysql = require('mysql2/promise');


const requiredEnvironmentVariables = [
'DB_HOST',
'DB_NAME',
'DB_USER'
];

for (const variableName of requiredEnvironmentVariables) {
if (!process.env[variableName]) {
throw new Error(
`Missing required environment variable: ${variableName}`
);
}
}

const pool = mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 3306),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD || '',
waitForConnections: true,
connectionLimit: Number(
process.env.DB_CONNECTION_LIMIT || 10
),
queueLimit: 0,
charset: 'utf8mb4',
decimalNumbers: true
});

async function testDatabaseConnection() {
const connection = await pool.getConnection();


try {
    await connection.ping();
    console.log('MySQL database connection established');
} finally {
    connection.release();
}


}

module.exports = {
pool,
testDatabaseConnection
};

Why Use a Connection Pool?

A connection pool manages and reuses multiple database connections. This is generally more efficient than opening a new connection for every API request.

Why Use mysql2/promise?

The Promise API allows database operations to use async and await, making asynchronous code easier to read and maintain.

Step 10: Create a Custom Application Error

Create src/utils/AppError.js:

class AppError extends Error {
constructor(message, statusCode = 500, details = null) {
    super(message);

    this.name = 'AppError';
    this.statusCode = statusCode;
    this.details = details;
    this.isOperational = true;

    Error.captureStackTrace(this, this.constructor);
}


}

module.exports = AppError;

This class lets the application create predictable errors with an HTTP status code and optional validation details.

Step 11: Create the Async Handler

Create src/middleware/asyncHandler.js:

function asyncHandler(handler) {
return function wrappedHandler(req, res, next) {
    Promise.resolve(
        handler(req, res, next)
    ).catch(next);
};


}

module.exports = asyncHandler;

This wrapper forwards rejected promises to Express error-handling middleware.

Step 12: Create the Product Model

Create src/models/productModel.js:

const { pool } = require('../config/database');


async function findAll({
search,
status,
minPrice,
maxPrice,
sort,
order,
limit,
offset
}) {
const conditions = [];
const parameters = [];


if (search) {
    conditions.push(
        '(name LIKE ? OR sku LIKE ? OR description LIKE ?)'
    );

    const searchValue = `%${search}%`;

    parameters.push(
        searchValue,
        searchValue,
        searchValue
    );
}

if (status) {
    conditions.push('status = ?');
    parameters.push(status);
}

if (minPrice !== null) {
    conditions.push('price >= ?');
    parameters.push(minPrice);
}

if (maxPrice !== null) {
    conditions.push('price <= ?');
    parameters.push(maxPrice);
}

const whereClause = conditions.length
    ? `WHERE ${conditions.join(' AND ')}`
    : '';

const allowedSortColumns = {
    id: 'id',
    name: 'name',
    sku: 'sku',
    price: 'price',
    stock: 'stock',
    created_at: 'created_at',
    updated_at: 'updated_at'
};

const sortColumn = allowedSortColumns[sort]
    || 'created_at';

const sortOrder = order === 'asc'
    ? 'ASC'
    : 'DESC';

const dataSql = `
    SELECT
        id,
        name,
        sku,
        description,
        price,
        stock,
        status,
        created_at,
        updated_at
    FROM products
    ${whereClause}
    ORDER BY ${sortColumn} ${sortOrder}, id DESC
    LIMIT ?
    OFFSET ?
`;

const countSql = `
    SELECT COUNT(*) AS total
    FROM products
    ${whereClause}
`;

const [rows] = await pool.execute(
    dataSql,
    [
        ...parameters,
        limit,
        offset
    ]
);

const [countRows] = await pool.execute(
    countSql,
    parameters
);

return {
    rows,
    total: Number(countRows[0].total)
};


}

async function findById(id) {
const [rows] = await pool.execute(
`             SELECT
                id,
                name,
                sku,
                description,
                price,
                stock,
                status,
                created_at,
                updated_at
            FROM products
            WHERE id = ?
            LIMIT 1
        `,
[id]
);


return rows[0] || null;


}

async function findBySku(sku, excludeId = null) {
let sql = `         SELECT id, sku
        FROM products
        WHERE sku = ?
    `;


const parameters = [sku];

if (excludeId !== null) {
    sql += ' AND id != ?';
    parameters.push(excludeId);
}

sql += ' LIMIT 1';

const [rows] = await pool.execute(
    sql,
    parameters
);

return rows[0] || null;


}

async function create({
name,
sku,
description,
price,
stock,
status
}) {
const [result] = await pool.execute(
`             INSERT INTO products
                (
                    name,
                    sku,
                    description,
                    price,
                    stock,
                    status
                )
            VALUES (?, ?, ?, ?, ?, ?)
        `,
[
name,
sku,
description,
price,
stock,
status
]
);


return findById(result.insertId);


}

async function replace(id, {
name,
sku,
description,
price,
stock,
status
}) {
const [result] = await pool.execute(
`             UPDATE products
            SET
                name = ?,
                sku = ?,
                description = ?,
                price = ?,
                stock = ?,
                status = ?
            WHERE id = ?
        `,
[
name,
sku,
description,
price,
stock,
status,
id
]
);


if (result.affectedRows === 0) {
    return null;
}

return findById(id);


}

async function update(id, updates) {
const allowedColumns = {
name: 'name',
sku: 'sku',
description: 'description',
price: 'price',
stock: 'stock',
status: 'status'
};


const assignments = [];
const parameters = [];

for (const [field, value] of Object.entries(updates)) {
    const column = allowedColumns[field];

    if (!column) {
        continue;
    }

    assignments.push(`${column} = ?`);
    parameters.push(value);
}

if (assignments.length === 0) {
    return findById(id);
}

parameters.push(id);

const [result] = await pool.execute(
    `
        UPDATE products
        SET ${assignments.join(', ')}
        WHERE id = ?
    `,
    parameters
);

if (result.affectedRows === 0) {
    return null;
}

return findById(id);


}

async function remove(id) {
const product = await findById(id);


if (!product) {
    return null;
}

await pool.execute(
    'DELETE FROM products WHERE id = ?',
    [id]
);

return product;


}

module.exports = {
findAll,
findById,
findBySku,
create,
replace,
update,
remove
};
The model uses placeholders such as ? for submitted values instead of directly inserting user input into SQL queries.

Step 13: Create the Product Service

Create src/services/productService.js:

const productModel = require('../models/productModel');


const AppError = require('../utils/AppError');

const allowedStatuses = ['active', 'inactive'];

function normalizeText(value) {
return String(value ?? '').trim();
}

function validateId(value) {
const id = Number(value);


if (!Number.isSafeInteger(id) || id < 1) {
    throw new AppError(
        'Product ID must be a positive integer',
        400
    );
}

return id;


}

function normalizeProductInput(input, partial = false) {
const errors = {};
const normalized = {};


const allowedFields = [
    'name',
    'sku',
    'description',
    'price',
    'stock',
    'status'
];

const unknownFields = Object.keys(input).filter(
    field => !allowedFields.includes(field)
);

if (unknownFields.length > 0) {
    errors.unknownFields =
        `Unsupported fields: ${unknownFields.join(', ')}`;
}

if (!partial || input.name !== undefined) {
    const name = normalizeText(input.name);

    if (!name) {
        errors.name = 'Name is required';
    } else if (name.length > 150) {
        errors.name =
            'Name cannot exceed 150 characters';
    } else {
        normalized.name = name;
    }
}

if (!partial || input.sku !== undefined) {
    const sku = normalizeText(input.sku).toUpperCase();

    if (!sku) {
        errors.sku = 'SKU is required';
    } else if (sku.length > 100) {
        errors.sku =
            'SKU cannot exceed 100 characters';
    } else {
        normalized.sku = sku;
    }
}

if (!partial || input.description !== undefined) {
    const description = input.description === null
        ? null
        : normalizeText(input.description);

    normalized.description =
        description === '' ? null : description;
}

if (!partial || input.price !== undefined) {
    const price = Number(input.price);

    if (!Number.isFinite(price) || price < 0) {
        errors.price =
            'Price must be a non-negative number';
    } else {
        normalized.price =
            Math.round(price * 1000) / 1000;
    }
}

if (!partial || input.stock !== undefined) {
    const stock = Number(input.stock);

    if (!Number.isSafeInteger(stock) || stock < 0) {
        errors.stock =
            'Stock must be a non-negative integer';
    } else {
        normalized.stock = stock;
    }
}

if (!partial || input.status !== undefined) {
    const status = normalizeText(
        input.status || 'active'
    ).toLowerCase();

    if (!allowedStatuses.includes(status)) {
        errors.status =
            'Status must be active or inactive';
    } else {
        normalized.status = status;
    }
}

if (Object.keys(errors).length > 0) {
    throw new AppError(
        'Product validation failed',
        422,
        errors
    );
}

return normalized;


}

async function listProducts(query) {
const page = Math.max(
Number.parseInt(query.page, 10) || 1,
1
);


const limit = Math.min(
    Math.max(
        Number.parseInt(query.limit, 10) || 10,
        1
    ),
    100
);

const search = normalizeText(query.search);

const status = query.status
    ? normalizeText(query.status).toLowerCase()
    : '';

if (status && !allowedStatuses.includes(status)) {
    throw new AppError(
        'Status filter must be active or inactive',
        400
    );
}

const minPrice = query.minPrice !== undefined
    ? Number(query.minPrice)
    : null;

const maxPrice = query.maxPrice !== undefined
    ? Number(query.maxPrice)
    : null;

if (
    minPrice !== null
    && (!Number.isFinite(minPrice) || minPrice < 0)
) {
    throw new AppError(
        'minPrice must be a non-negative number',
        400
    );
}

if (
    maxPrice !== null
    && (!Number.isFinite(maxPrice) || maxPrice < 0)
) {
    throw new AppError(
        'maxPrice must be a non-negative number',
        400
    );
}

if (
    minPrice !== null
    && maxPrice !== null
    && minPrice > maxPrice
) {
    throw new AppError(
        'minPrice cannot be greater than maxPrice',
        400
    );
}

const allowedSorts = [
    'id',
    'name',
    'sku',
    'price',
    'stock',
    'created_at',
    'updated_at'
];

const sort = allowedSorts.includes(query.sort)
    ? query.sort
    : 'created_at';

const order = String(query.order).toLowerCase() === 'asc'
    ? 'asc'
    : 'desc';

const offset = (page - 1) * limit;

const result = await productModel.findAll({
    search,
    status,
    minPrice,
    maxPrice,
    sort,
    order,
    limit,
    offset
});

return {
    products: result.rows,
    pagination: {
        page,
        limit,
        total: result.total,
        totalPages: Math.ceil(result.total / limit)
    }
};


}

async function getProduct(idValue) {
const id = validateId(idValue);
const product = await productModel.findById(id);


if (!product) {
    throw new AppError('Product not found', 404);
}

return product;


}

async function createProduct(input) {
const productData = normalizeProductInput(input);


const existingProduct = await productModel.findBySku(
    productData.sku
);

if (existingProduct) {
    throw new AppError(
        'A product with this SKU already exists',
        409
    );
}

return productModel.create(productData);


}

async function replaceProduct(idValue, input) {
const id = validateId(idValue);


await getProduct(id);

const productData = normalizeProductInput(input);

const existingProduct = await productModel.findBySku(
    productData.sku,
    id
);

if (existingProduct) {
    throw new AppError(
        'A product with this SKU already exists',
        409
    );
}

return productModel.replace(id, productData);


}

async function updateProduct(idValue, input) {
const id = validateId(idValue);


await getProduct(id);

const productData = normalizeProductInput(
    input,
    true
);

if (Object.keys(productData).length === 0) {
    throw new AppError(
        'Provide at least one valid field to update',
        400
    );
}

if (productData.sku !== undefined) {
    const existingProduct = await productModel.findBySku(
        productData.sku,
        id
    );

    if (existingProduct) {
        throw new AppError(
            'A product with this SKU already exists',
            409
        );
    }
}

return productModel.update(id, productData);


}

async function deleteProduct(idValue) {
const id = validateId(idValue);
const product = await productModel.remove(id);


if (!product) {
    throw new AppError('Product not found', 404);
}

return product;


}

module.exports = {
listProducts,
getProduct,
createProduct,
replaceProduct,
updateProduct,
deleteProduct
};

Step 14: Create the Product Controller

Create src/controllers/productController.js:

const productService = require('../services/productService');


async function index(req, res) {
const result = await productService.listProducts(
req.query
);


res.status(200).json({
    success: true,
    message: 'Products retrieved successfully',
    pagination: result.pagination,
    data: result.products
});


}

async function show(req, res) {
const product = await productService.getProduct(
req.params.id
);


res.status(200).json({
    success: true,
    message: 'Product retrieved successfully',
    data: product
});


}

async function store(req, res) {
const product = await productService.createProduct(
req.body
);


res
    .status(201)
    .location(`/api/v1/products/${product.id}`)
    .json({
        success: true,
        message: 'Product created successfully',
        data: product
    });


}

async function replace(req, res) {
const product = await productService.replaceProduct(
req.params.id,
req.body
);


res.status(200).json({
    success: true,
    message: 'Product replaced successfully',
    data: product
});


}

async function update(req, res) {
const product = await productService.updateProduct(
req.params.id,
req.body
);


res.status(200).json({
    success: true,
    message: 'Product updated successfully',
    data: product
});


}

async function destroy(req, res) {
const product = await productService.deleteProduct(
req.params.id
);


res.status(200).json({
    success: true,
    message: 'Product deleted successfully',
    data: product
});


}

module.exports = {
index,
show,
store,
replace,
update,
destroy
};

Step 15: Create the Product Routes

Create src/routes/productRoutes.js:

const express = require('express');


const productController =
require('../controllers/productController');

const asyncHandler =
require('../middleware/asyncHandler');

const router = express.Router();

router.get(
'/',
asyncHandler(productController.index)
);

router.get(
'/:id',
asyncHandler(productController.show)
);

router.post(
'/',
asyncHandler(productController.store)
);

router.put(
'/:id',
asyncHandler(productController.replace)
);

router.patch(
'/:id',
asyncHandler(productController.update)
);

router.delete(
'/:id',
asyncHandler(productController.destroy)
);

module.exports = router;
Learn how routes and HTTP methods work in Express.js Routing Explained.

Step 16: Create the 404 Middleware

Create src/middleware/notFound.js:

const AppError = require('../utils/AppError');


function notFound(req, res, next) {
next(
new AppError(
`Endpoint not found: ${req.method} ${req.originalUrl}`,
404
)
);
}

module.exports = notFound;

Step 17: Create the Error Handler

Create src/middleware/errorHandler.js:

function errorHandler(err, req, res, next) {
console.error(err);

let statusCode = Number.isInteger(err.statusCode)
    ? err.statusCode
    : 500;

let message = err.message
    || 'An unexpected server error occurred';

let details = err.details || null;

if (err.code === 'ER_DUP_ENTRY') {
    statusCode = 409;
    message = 'A record with the same unique value exists';
}

if (err.code === 'ER_BAD_DB_ERROR') {
    statusCode = 500;
    message = 'The configured database does not exist';
}

if (err.code === 'ECONNREFUSED') {
    statusCode = 503;
    message = 'The database service is unavailable';
}

const response = {
    success: false,
    message
};

if (details) {
    response.errors = details;
}

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

res.status(statusCode).json(response);


}

module.exports = errorHandler;
Production applications should log detailed technical errors internally without returning stack traces, SQL statements, credentials, or file paths to clients.

Step 18: Create the Express Application

Create src/app.js:

const express = require('express');


const productRoutes = require('./routes/productRoutes');
const notFound = require('./middleware/notFound');
const errorHandler = require('./middleware/errorHandler');

const app = express();

app.disable('x-powered-by');

app.use(express.json({
limit: '1mb'
}));

app.use(express.urlencoded({
extended: false,
limit: '1mb'
}));

app.get('/api/health', (req, res) => {
res.status(200).json({
success: true,
message: 'API is healthy',
timestamp: new Date().toISOString()
});
});

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

app.use(notFound);
app.use(errorHandler);

module.exports = app;

Middleware order matters. JSON parsing must run before routes, while the 404 and error handlers must be registered after the routes.

Read Express.js Middleware Explained to learn more about middleware execution order.

Step 19: Create the Server Entry Point

Create server.js:

require('dotenv').config();


const app = require('./src/app');

const {
testDatabaseConnection
} = require('./src/config/database');

const PORT = Number(process.env.PORT || 3000);

async function startServer() {
try {
await testDatabaseConnection();


    app.listen(PORT, () => {
        console.log(
            `API running at http://localhost:${PORT}`
        );
    });
} catch (error) {
    console.error(
        'Application startup failed:',
        error.message
    );

    process.exit(1);
}


}

startServer();

Step 20: Start the Application

npm run dev

A successful startup should display messages similar to:

MySQL database connection established


API running at http://localhost:3000

Test the Health Endpoint

GET http://localhost:3000/api/health

Expected response:

{
"success": true,
"message": "API is healthy",
"timestamp": "2026-07-18T12:00:00.000Z"


}

Retrieve All Products

GET http://localhost:3000/api/v1/products

Example response:

{
"success": true,
"message": "Products retrieved successfully",
"pagination": {
    "page": 1,
    "limit": 10,
    "total": 3,
    "totalPages": 1
},
"data": [
    {
        "id": 1,
        "name": "Business Laptop",
        "sku": "LAP-001",
        "description": "Laptop for office and business use",
        "price": 325,
        "stock": 10,
        "status": "active"
    }
]


}

Search, Filter, Sort, and Paginate Products

The listing endpoint supports these query parameters:

Parameter Example Purpose
search search=laptop Searches the name, SKU, and description
status status=active Filters by active or inactive status
minPrice minPrice=10 Sets the minimum price
maxPrice maxPrice=500 Sets the maximum price
sort sort=price Selects the sorting column
order order=asc Chooses ascending or descending order
page page=2 Selects the result page
limit limit=20 Controls the records returned per page

Example request:

GET /api/v1/products?search=lap&status=active&minPrice=100&maxPrice=500&sort=price&order=asc&page=1&limit=10

Retrieve One Product

GET http://localhost:3000/api/v1/products/1

A missing product returns:

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


}

Create a Product

POST http://localhost:3000/api/v1/products

Set the request header:

Content-Type: application/json

Request body:

{
"name": "27-inch Monitor",
"sku": "MON-001",
"description": "27-inch business monitor",
"price": 89.900,
"stock": 12,
"status": "active"


}

A successful creation returns HTTP status 201 Created.

Replace a Product with PUT

PUT http://localhost:3000/api/v1/products/1
{
"name": "Professional Business Laptop",
"sku": "LAP-001",
"description": "Updated business laptop",
"price": 399.000,
"stock": 8,
"status": "active"


}
The PUT endpoint expects all required product fields because it represents a complete replacement.

Partially Update a Product with PATCH

PATCH http://localhost:3000/api/v1/products/1
{
"price": 375.000,
"stock": 15


}

PATCH only changes the submitted fields.

Delete a Product

DELETE http://localhost:3000/api/v1/products/1

Successful response:

{
"success": true,
"message": "Product deleted successfully",
"data": {
    "id": 1,
    "name": "Professional Business Laptop",
    "sku": "LAP-001"
}


}

Testing with cURL

List Products

curl http://localhost:3000/api/v1/products

Create a Product

curl -X POST http://localhost:3000/api/v1/products \


-H "Content-Type: application/json" 
-d "{"name":"USB Hub","sku":"HUB-001","description":"Four-port USB hub","price":9.500,"stock":25,"status":"active"}"

Update Stock

curl -X PATCH http://localhost:3000/api/v1/products/1 \


-H "Content-Type: application/json" 
-d "{"stock":20}"

Delete a Product

curl -X DELETE http://localhost:3000/api/v1/products/1

How Validation Errors Work

Sending an invalid product:

{
"name": "",
"sku": "",
"price": -10,
"stock": 1.5,
"status": "unknown"


}

Returns a response similar to:

{
"success": false,
"message": "Product validation failed",
"errors": {
    "name": "Name is required",
    "sku": "SKU is required",
    "price": "Price must be a non-negative number",
    "stock": "Stock must be a non-negative integer",
    "status": "Status must be active or inactive"
}


}

Why Parameterized Statements Matter

Avoid building SQL queries by joining untrusted user input:

// Unsafe approach


const sql =
"SELECT * FROM products WHERE sku = '"
+ req.params.sku
+ "'";

Use placeholders instead:

const [rows] = await pool.execute(
'SELECT * FROM products WHERE sku = ?',
[req.params.sku]


);
Validation does not replace parameterized database statements. Production applications should use both.

Common Errors and Solutions

Ensure MySQL is running and verify the host and port values in the .env file.

Check the MySQL username, password, host permissions, and database user privileges.

Confirm that the database exists and that DB_NAME matches its exact name.

Another product already uses the submitted SKU. Use a unique SKU or update the existing product.

Confirm that express.json() runs before the routes and that the request uses the Content-Type: application/json header.
For additional troubleshooting, read Common Node.js Errors and How to Fix Them.

Production Improvements

The API is now functional, but a public production system should add more protections and operational features.

  • Authentication using secure sessions, API keys, OAuth, or JWT
  • Role-based authorization and permission checks
  • Rate limiting for public endpoints
  • Helmet security headers
  • Carefully restricted CORS configuration
  • Structured application and audit logs
  • Database migrations and seed scripts
  • Transactions for multi-step database operations
  • Automated unit, integration, and API tests
  • API documentation with OpenAPI or Swagger
  • HTTPS and secure production environment variables
  • Monitoring, health checks, and database backups

Recommended API Endpoints

Method Endpoint Purpose
GET /api/health Check API health
GET /api/v1/products List, search, filter, sort, and paginate products
GET /api/v1/products/:id Retrieve one product
POST /api/v1/products Create a product
PUT /api/v1/products/:id Replace a product
PATCH /api/v1/products/:id Partially update a product
DELETE /api/v1/products/:id Delete a product

Frequently Asked Questions

Yes. Express.js applications can connect to MySQL using a Node.js database driver such as MySQL2.

An array loses its data when the application restarts. MySQL stores records permanently and supports searching, indexing, relationships, transactions, and concurrent access.

A pool is generally more suitable for web APIs because it manages and reuses multiple database connections across requests.

The tutorial demonstrates CRUD and database architecture. Authentication and authorization must be added before using the API for private or administrative data.

Yes. This project should work with common MariaDB installations, although you should test SQL features and configuration against your specific server version.

Conclusion

You have now built a complete CRUD REST API using Express.js, Node.js, MySQL, and MySQL2. The API includes permanent database storage, modular routes, controllers, services, models, input validation, pagination, filtering, sorting, parameterized SQL statements, and centralized error handling.

This architecture can be expanded into a product catalog, inventory system, ERP module, HRMS platform, warehouse management application, e-commerce backend, mobile application API, or another database-driven system.

The next important step is adding authentication and authorization so private endpoints can identify users and enforce permissions.

About ShasTech-IT

ShasTech-IT develops Node.js and Express.js APIs, MySQL applications, ERP systems, HRMS platforms, warehouse management software, POS applications, Android apps, and custom business solutions.

Contact WhatsApp