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.
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.
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;
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
.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
};
? 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;
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;
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"
}
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]
);
Common Errors and Solutions
.env file.
DB_NAME matches its exact name.
express.json() runs before the routes and that the request uses the Content-Type: application/json header.
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
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.