Building REST APIs with Express.js: Complete Beginner-to-Production Guide

REST APIs allow web applications, mobile apps, desktop software, and third-party services to communicate with a server. Express.js makes REST API development with Node.js…

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

Article

REST APIs allow web applications, mobile apps, desktop software, and third-party services to communicate with a server. Express.js makes REST API development with Node.js simple, flexible, and suitable for projects ranging from small applications to enterprise systems.

What You Will Build: A complete product REST API with GET, POST, PUT, PATCH, and DELETE endpoints, request validation, error handling, filtering, pagination, and a scalable project structure.

This tutorial assumes that you already understand the basics of Node.js and Express.js. New developers should first read Express.js Tutorial for Beginners: Build Your First Web Server.

What Is a REST API?

REST stands for Representational State Transfer. A REST API exposes resources through URLs and uses standard HTTP methods to perform operations on those resources.

For example, a product management API may use the following endpoints:

HTTP Method Endpoint Purpose
GET /api/products Retrieve all products
GET /api/products/:id Retrieve one product
POST /api/products Create a new product
PUT /api/products/:id Replace or fully update a product
PATCH /api/products/:id Partially update a product
DELETE /api/products/:id Delete a product
To understand HTTP methods in greater detail, read Express.js Routing Explained: GET, POST, PUT & DELETE Requests.

Prerequisites

Before starting, make sure your computer has the following:

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

You can verify your Node.js and npm installations with:

node -v


npm -v

Developers who need to switch between Node.js versions can read How to Use NVM (Node Version Manager) Like a Pro.

Step 1: Create the Express.js Project

Open a terminal and create a new project directory:

mkdir express-rest-api


cd express-rest-api

Initialize the Node.js project:

npm init -y

This command creates a package.json file that stores project metadata, scripts, and dependencies.

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

Step 2: Install Express.js

npm install express

For development, install Nodemon so the application automatically restarts after code changes:

npm install --save-dev nodemon

Update the scripts section of package.json:

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


}

Step 3: Create the Express Server

Create a file named server.js:

const express = require('express');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

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

app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});

Start the development server:

npm run dev

Open the following address in your browser:

http://localhost:3000

Why express.json() Is Important

The following middleware allows Express.js to parse incoming JSON request bodies:

app.use(express.json());

Without it, req.body will usually be undefined when a client submits JSON data.

Learn more about request-processing functions in Express.js Middleware Explained: Complete Beginner's Guide.

Step 4: Create Sample Product Data

For this beginner tutorial, we will store products in an array. Later, this array can be replaced with MySQL, PostgreSQL, MongoDB, or another database.

let products = [
{
    id: 1,
    name: 'Laptop',
    price: 350,
    stock: 10
},
{
    id: 2,
    name: 'Keyboard',
    price: 15,
    stock: 25
},
{
    id: 3,
    name: 'Mouse',
    price: 8,
    stock: 40
}


];
In-memory data disappears whenever the server restarts. Use a database for real applications.

Step 5: Create a GET Endpoint for All Products

Add the following route below the products array:

app.get('/api/products', (req, res) => {
res.status(200).json({
    success: true,
    count: products.length,
    data: products
});


});

Send a GET request to:

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

The response will resemble:

{
"success": true,
"count": 3,
"data": [
    {
        "id": 1,
        "name": "Laptop",
        "price": 350,
        "stock": 10
    }
]


}

Step 6: Retrieve One Product by ID

app.get('/api/products/:id', (req, res) => {
const productId = Number(req.params.id);

const product = products.find(item => item.id === productId);

if (!product) {
    return res.status(404).json({
        success: false,
        message: 'Product not found'
    });
}

return res.status(200).json({
    success: true,
    data: product
});


});

Test the route with:

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

Why Number() Is Used

Route parameters are received as strings. Since product IDs are numeric, the value should be converted before comparing it:

const productId = Number(req.params.id);

Step 7: Create a Product with POST

app.post('/api/products', (req, res) => {
const { name, price, stock } = req.body;

if (!name || price === undefined || stock === undefined) {
    return res.status(400).json({
        success: false,
        message: 'Name, price, and stock are required'
    });
}

const parsedPrice = Number(price);
const parsedStock = Number(stock);

if (
    !Number.isFinite(parsedPrice) ||
    parsedPrice < 0 ||
    !Number.isInteger(parsedStock) ||
    parsedStock < 0
) {
    return res.status(422).json({
        success: false,
        message: 'Price and stock must contain valid non-negative values'
    });
}

const nextId = products.length
    ? Math.max(...products.map(product => product.id)) + 1
    : 1;

const newProduct = {
    id: nextId,
    name: String(name).trim(),
    price: parsedPrice,
    stock: parsedStock
};

products.push(newProduct);

return res.status(201).json({
    success: true,
    message: 'Product created successfully',
    data: newProduct
});


});

Send the following JSON request body:

{
"name": "Monitor",
"price": 85,
"stock": 12


}

Send it to:

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

Step 8: Update a Product with PUT

A PUT request is commonly used to replace or fully update an existing resource.

app.put('/api/products/:id', (req, res) => {
const productId = Number(req.params.id);
const productIndex = products.findIndex(item => item.id === productId);

if (productIndex === -1) {
    return res.status(404).json({
        success: false,
        message: 'Product not found'
    });
}

const { name, price, stock } = req.body;

if (!name || price === undefined || stock === undefined) {
    return res.status(400).json({
        success: false,
        message: 'Name, price, and stock are required'
    });
}

const parsedPrice = Number(price);
const parsedStock = Number(stock);

if (
    !Number.isFinite(parsedPrice) ||
    parsedPrice < 0 ||
    !Number.isInteger(parsedStock) ||
    parsedStock < 0
) {
    return res.status(422).json({
        success: false,
        message: 'Invalid price or stock value'
    });
}

const updatedProduct = {
    id: productId,
    name: String(name).trim(),
    price: parsedPrice,
    stock: parsedStock
};

products[productIndex] = updatedProduct;

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


});

Example request:

PUT http://localhost:3000/api/products/1
{
"name": "Business Laptop",
"price": 425,
"stock": 8


}

Step 9: Partially Update a Product with PATCH

PATCH is useful when the client only needs to update selected fields.

app.patch('/api/products/:id', (req, res) => {
const productId = Number(req.params.id);
const product = products.find(item => item.id === productId);

if (!product) {
    return res.status(404).json({
        success: false,
        message: 'Product not found'
    });
}

const allowedFields = ['name', 'price', 'stock'];
const submittedFields = Object.keys(req.body);

const invalidFields = submittedFields.filter(
    field => !allowedFields.includes(field)
);

if (invalidFields.length > 0) {
    return res.status(400).json({
        success: false,
        message: `Invalid fields: ${invalidFields.join(', ')}`
    });
}

if (req.body.name !== undefined) {
    const name = String(req.body.name).trim();

    if (!name) {
        return res.status(422).json({
            success: false,
            message: 'Name cannot be empty'
        });
    }

    product.name = name;
}

if (req.body.price !== undefined) {
    const price = Number(req.body.price);

    if (!Number.isFinite(price) || price < 0) {
        return res.status(422).json({
            success: false,
            message: 'Price must be a valid non-negative number'
        });
    }

    product.price = price;
}

if (req.body.stock !== undefined) {
    const stock = Number(req.body.stock);

    if (!Number.isInteger(stock) || stock < 0) {
        return res.status(422).json({
            success: false,
            message: 'Stock must be a non-negative integer'
        });
    }

    product.stock = stock;
}

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


});

Example request:

PATCH http://localhost:3000/api/products/1
{
"stock": 20


}

PUT vs PATCH

Method Typical Purpose Request Data
PUT Replace or fully update a resource Usually contains every required field
PATCH Partially update a resource Contains only fields that should change

Step 10: Delete a Product

app.delete('/api/products/:id', (req, res) => {
const productId = Number(req.params.id);
const productIndex = products.findIndex(item => item.id === productId);

if (productIndex === -1) {
    return res.status(404).json({
        success: false,
        message: 'Product not found'
    });
}

const deletedProduct = products[productIndex];

products.splice(productIndex, 1);

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


});

Test the endpoint:

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

Add Search and Filtering

Query parameters can be used to filter products without creating separate routes.

app.get('/api/products', (req, res) => {
const search = String(req.query.search || '').trim().toLowerCase();
const minPrice = req.query.minPrice !== undefined
    ? Number(req.query.minPrice)
    : null;
const maxPrice = req.query.maxPrice !== undefined
    ? Number(req.query.maxPrice)
    : null;

let results = [...products];

if (search) {
    results = results.filter(product =>
        product.name.toLowerCase().includes(search)
    );
}

if (minPrice !== null && Number.isFinite(minPrice)) {
    results = results.filter(product => product.price >= minPrice);
}

if (maxPrice !== null && Number.isFinite(maxPrice)) {
    results = results.filter(product => product.price <= maxPrice);
}

return res.status(200).json({
    success: true,
    count: results.length,
    data: results
});


});

Example:

GET /api/products?search=lap&minPrice=100&maxPrice=500

Add Pagination

Pagination prevents an API from returning thousands of records in one response.

app.get('/api/products', (req, res) => {
const page = Math.max(Number.parseInt(req.query.page, 10) || 1, 1);
const limit = Math.min(
    Math.max(Number.parseInt(req.query.limit, 10) || 10, 1),
    100
);

const startIndex = (page - 1) * limit;
const paginatedProducts = products.slice(
    startIndex,
    startIndex + limit
);

return res.status(200).json({
    success: true,
    pagination: {
        page,
        limit,
        total: products.length,
        totalPages: Math.ceil(products.length / limit)
    },
    data: paginatedProducts
});


});

Example:

GET /api/products?page=2&limit=10

Use Proper HTTP Status Codes

Status Code Meaning Common Usage
200 OK Successful GET, PUT, PATCH, or DELETE request
201 Created A new resource was created
204 No Content Successful request with no response body
400 Bad Request Malformed or incomplete input
401 Unauthorized Authentication is missing or invalid
403 Forbidden The authenticated user lacks permission
404 Not Found The requested resource does not exist
409 Conflict Duplicate or conflicting data
422 Unprocessable Content Validation failed
429 Too Many Requests Rate limit exceeded
500 Internal Server Error An unexpected server-side error occurred

Add a 404 Handler

Place this middleware after all registered routes:

app.use((req, res) => {
res.status(404).json({
    success: false,
    message: 'API endpoint not found'
});


});

Add Centralized Error Handling

Error-handling middleware must contain four parameters:

app.use((err, req, res, next) => {
console.error(err);

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

res.status(statusCode).json({
    success: false,
    message: statusCode === 500
        ? 'An unexpected server error occurred'
        : err.message
});


});
Do not expose stack traces, database credentials, file paths, or sensitive internal errors in production API responses.

Handle Asynchronous Route Errors

Database operations and external API calls are usually asynchronous. A reusable wrapper can forward rejected promises to the central error handler.

const asyncHandler = handler => {
return (req, res, next) => {
    Promise.resolve(handler(req, res, next)).catch(next);
};


};

app.get('/api/reports', asyncHandler(async (req, res) => {
const reports = await loadReports();


res.status(200).json({
    success: true,
    data: reports
});


}));

Recommended REST API Response Format

Consistent responses make APIs easier for frontend and mobile developers to consume.

Successful Response

{
"success": true,
"message": "Product created successfully",
"data": {
    "id": 4,
    "name": "Monitor"
}


}

Error Response

{
"success": false,
"message": "Product not found",
"errors": []


}

Organize the API into Separate Files

Keeping every route inside server.js becomes difficult as an application grows.

A scalable structure may look like this:

express-rest-api/


│
├── src/
│   ├── config/
│   │   └── database.js
│   ├── controllers/
│   │   └── productController.js
│   ├── middleware/
│   │   ├── errorHandler.js
│   │   └── validateProduct.js
│   ├── models/
│   │   └── Product.js
│   ├── routes/
│   │   └── productRoutes.js
│   ├── services/
│   │   └── productService.js
│   └── app.js
│
├── tests/
├── .env
├── .gitignore
├── package.json
└── server.js

Create a Product Router

Create src/routes/productRoutes.js:

const express = require('express');


const router = express.Router();

router.get('/', (req, res) => {
res.json({
success: true,
message: 'Get all products'
});
});

router.get('/:id', (req, res) => {
res.json({
success: true,
message: `Get product ${req.params.id}`
});
});

router.post('/', (req, res) => {
res.status(201).json({
success: true,
message: 'Create product'
});
});

router.put('/:id', (req, res) => {
res.json({
success: true,
message: `Replace product ${req.params.id}`
});
});

router.patch('/:id', (req, res) => {
res.json({
success: true,
message: `Update product ${req.params.id}`
});
});

router.delete('/:id', (req, res) => {
res.json({
success: true,
message: `Delete product ${req.params.id}`
});
});

module.exports = router;

Mount the router in the application:

const productRoutes = require('./src/routes/productRoutes');


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

Separate Controllers from Routes

Routes should define URLs, while controllers should handle request and response logic.

Create src/controllers/productController.js:

function getProducts(req, res) {
res.status(200).json({
    success: true,
    data: []
});


}

function createProduct(req, res) {
res.status(201).json({
success: true,
message: 'Product created',
data: req.body
});
}

module.exports = {
getProducts,
createProduct
};

Then update the router:

const express = require('express');


const {
getProducts,
createProduct
} = require('../controllers/productController');

const router = express.Router();

router.get('/', getProducts);
router.post('/', createProduct);

module.exports = router;

Use Environment Variables

Install the environment-variable package:

npm install dotenv

Create a .env file:

PORT=3000


NODE_ENV=development
DATABASE_URL=your_database_connection
JWT_SECRET=replace_with_a_strong_secret

Load it at the beginning of the application:

require('dotenv').config();
Never commit the .env file to a public Git repository.

Create a .gitignore File

node_modules/


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

REST API Security Essentials

A production API needs more than CRUD routes. It should also include appropriate security controls.

  • Validate and normalize every request.
  • Use authentication for private endpoints.
  • Check authorization and user permissions.
  • Use HTTPS in production.
  • Limit request body sizes.
  • Add rate limiting.
  • Configure CORS carefully.
  • Use parameterized database queries.
  • Avoid returning confidential database fields.
  • Record security-relevant activities in audit logs.

Install Common Security Middleware

npm install helmet cors express-rate-limit
const helmet = require('helmet');


const cors = require('cors');
const rateLimit = require('express-rate-limit');

app.use(helmet());

app.use(cors({
origin: ['https://example.com'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
credentials: true
}));

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

const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: true,
legacyHeaders: false
});

app.use('/api', apiLimiter);
Avoid using unrestricted CORS settings for APIs that handle private or sensitive information.

API Versioning

API versioning allows you to introduce breaking changes without immediately affecting existing applications.

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

A later version may use:

/api/v2/products

Testing the API with cURL

GET Products

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

Create a Product

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


-H "Content-Type: application/json" 
-d "{"name":"Monitor","price":85,"stock":12}"

Update a Product

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


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

Delete a Product

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

Common REST API Mistakes

Use GET to retrieve data, POST to create resources, PUT or PATCH to update resources, and DELETE to remove resources.

Use meaningful status codes such as 201 for newly created resources, 400 for invalid requests, and 404 when a resource does not exist.

Never assume submitted data is safe or correctly formatted. Validate types, formats, allowed fields, ranges, and permissions on the server.

Log technical errors internally, but return safe and understandable error messages to API clients.

Separate routes, controllers, middleware, models, services, and configuration files as the project grows.
Encountering installation, dependency, module, or port problems? Read Common Node.js Errors and How to Fix Them.

REST API Best-Practices Checklist

  • Use resource-based URL names such as /products.
  • Use plural nouns consistently.
  • Use the correct HTTP method.
  • Return appropriate HTTP status codes.
  • Validate every request.
  • Use consistent response structures.
  • Separate routes from controllers.
  • Implement centralized error handling.
  • Add filtering, sorting, and pagination.
  • Use authentication and permission checks.
  • Apply rate limits and request-size limits.
  • Store secrets in environment variables.
  • Use HTTPS in production.
  • Write automated tests.
  • Document endpoints and payloads.

Complete Beginner API Example

The following example combines the main concepts from this tutorial into a single file:

const express = require('express');


const app = express();
const PORT = process.env.PORT || 3000;

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

let products = [
{
id: 1,
name: 'Laptop',
price: 350,
stock: 10
}
];

app.get('/api/products', (req, res) => {
res.status(200).json({
success: true,
count: products.length,
data: products
});
});

app.get('/api/products/:id', (req, res) => {
const productId = Number(req.params.id);
const product = products.find(item => item.id === productId);


if (!product) {
    return res.status(404).json({
        success: false,
        message: 'Product not found'
    });
}

return res.status(200).json({
    success: true,
    data: product
});


});

app.post('/api/products', (req, res) => {
const { name, price, stock } = req.body;


if (!name || price === undefined || stock === undefined) {
    return res.status(400).json({
        success: false,
        message: 'Name, price, and stock are required'
    });
}

const parsedPrice = Number(price);
const parsedStock = Number(stock);

if (
    !Number.isFinite(parsedPrice) ||
    parsedPrice < 0 ||
    !Number.isInteger(parsedStock) ||
    parsedStock < 0
) {
    return res.status(422).json({
        success: false,
        message: 'Invalid price or stock'
    });
}

const nextId = products.length
    ? Math.max(...products.map(product => product.id)) + 1
    : 1;

const product = {
    id: nextId,
    name: String(name).trim(),
    price: parsedPrice,
    stock: parsedStock
};

products.push(product);

return res.status(201).json({
    success: true,
    message: 'Product created successfully',
    data: product
});


});

app.patch('/api/products/:id', (req, res) => {
const productId = Number(req.params.id);
const product = products.find(item => item.id === productId);


if (!product) {
    return res.status(404).json({
        success: false,
        message: 'Product not found'
    });
}

if (req.body.name !== undefined) {
    const name = String(req.body.name).trim();

    if (!name) {
        return res.status(422).json({
            success: false,
            message: 'Name cannot be empty'
        });
    }

    product.name = name;
}

if (req.body.price !== undefined) {
    const price = Number(req.body.price);

    if (!Number.isFinite(price) || price < 0) {
        return res.status(422).json({
            success: false,
            message: 'Invalid price'
        });
    }

    product.price = price;
}

if (req.body.stock !== undefined) {
    const stock = Number(req.body.stock);

    if (!Number.isInteger(stock) || stock < 0) {
        return res.status(422).json({
            success: false,
            message: 'Invalid stock'
        });
    }

    product.stock = stock;
}

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


});

app.delete('/api/products/:id', (req, res) => {
const productId = Number(req.params.id);
const productIndex = products.findIndex(
item => item.id === productId
);


if (productIndex === -1) {
    return res.status(404).json({
        success: false,
        message: 'Product not found'
    });
}

const [deletedProduct] = products.splice(productIndex, 1);

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


});

app.use((req, res) => {
res.status(404).json({
success: false,
message: 'API endpoint not found'
});
});

app.use((err, req, res, next) => {
console.error(err);


res.status(500).json({
    success: false,
    message: 'An unexpected server error occurred'
});


});

app.listen(PORT, () => {
console.log(`API running at http://localhost:${PORT}`);
});

Frequently Asked Questions

Yes. Express.js provides routing, middleware, request parsing, response handling, and a large package ecosystem for building REST APIs.

Express.js can work with MySQL, MariaDB, PostgreSQL, MongoDB, SQLite, Microsoft SQL Server, and many other databases.

Use PUT when replacing or fully updating a resource. Use PATCH when changing only selected fields.

Public read-only endpoints may not require authentication. Private, administrative, financial, or user-specific endpoints should normally require authentication and authorization.

Conclusion

Building a REST API with Express.js begins with understanding resources, routes, HTTP methods, request bodies, validation, and status codes. Once these fundamentals are in place, the API can be expanded with database integration, authentication, authorization, logging, documentation, and automated testing.

The in-memory product API created in this tutorial is suitable for learning. A real production application should store data in a database, separate responsibilities into modules, protect sensitive routes, validate all requests, and use centralized error handling.

About ShasTech-IT

ShasTech-IT develops REST APIs, Node.js applications, Express.js backends, ERP systems, HRMS platforms, warehouse management systems, POS applications, mobile apps, and custom business software.

Contact WhatsApp