If you need a data layer for a mobile app, SPA, or third-party integration, knowing how to build a REST API is essential. In this guide, you will build a production-grade product API with Express.js — including pagination, validation, and error handling.
What You Will Learn
- Core principles of REST architecture and proper HTTP method usage
- Setting up a modular router structure with Express.js
- Writing 5 CRUD endpoints (list, detail, create, update, delete)
- Securing input with a validation middleware
- Centralized error handling (custom error class + error middleware)
- Offset-based pagination with a standard response wrapper
- Testing every endpoint with curl
Step 1: What Is a REST API?
REST (Representational State Transfer) is an architectural style that standardizes client-server communication. Four principles define it:
Resource-based URLs. Each endpoint represents a resource. /api/v1/products points to the products collection; /api/v1/products/42 points to a single product. Verbs stay out of the URL — the HTTP method carries the action.
Statelessness. The server does not remember previous requests. Every request carries all the information needed to process it. This makes horizontal scaling straightforward: any server can handle any request.
JSON data format. Request and response bodies use JSON. The Content-Type: application/json header signals this.
HTTP methods carry meaning. Each method maps to a specific operation:
| Method | Purpose | Idempotent? | Has Body? |
|---|---|---|---|
| GET | Read resource | Yes | No |
| POST | Create new resource | No | Yes |
| PUT | Update resource (full or partial) | Yes | Yes |
| DELETE | Remove resource | Yes | No |
| PATCH | Partial update | No | Yes |
What is the difference between PUT and PATCH? PUT replaces the entire resource — fields you do not send get wiped. PATCH updates only the fields you send. In practice, most APIs use PUT for partial updates because it is the more common convention on the client side. That is what we will do in this guide.
Step 2: Project Setup
Open your terminal and create the project directory:
mkdir product-api && cd product-api
npm init -y
npm install express cors uuid
Three packages installed:
- express — HTTP server and routing
- cors — Allows cross-origin requests
- uuid — Generates unique product IDs
Create the project structure:
mkdir -p routes middleware
touch index.js routes/products.js middleware/validate.js middleware/errorHandler.js
Final layout:
product-api/
├── index.js
├── routes/
│ └── products.js
├── middleware/
│ ├── validate.js
│ └── errorHandler.js
├── package.json
└── node_modules/
Step 3: Server Entry Point and Router Structure
Open index.js and write the server skeleton:
const express = require('express');
const cors = require('cors');
const productRoutes = require('./routes/products');
const { errorHandler } = require('./middleware/errorHandler');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Routes
app.use('/api/v1/products', productRoutes);
// 404 handler — catches undefined routes
app.use((req, res) => {
res.status(404).json({ error: 'Endpoint not found' });
});
// Centralized error handler — must be last
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`API server running at http://localhost:${PORT}`);
});
Key points:
express.json()automatically parses incoming JSON bodies.- All product routes live under the
/api/v1/productsprefix. The version prefix (v1) lets you ship breaking changes underv2without breaking existing clients. - The 404 handler catches every route not explicitly defined.
errorHandleris defined last — Express error middleware takes 4 parameters (err, req, res, next), so order matters.
Step 4: CRUD Endpoints
Open routes/products.js. We will define an in-memory data store and helper functions first, then write all five endpoints.
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const { validateProduct } = require('../middleware/validate');
const router = express.Router();
// In-memory data store
let products = [
{
id: '550e8400-e29b-41d4-a716-446655440000',
name: 'Mechanical Keyboard',
price: 1250,
category: 'Electronics',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
name: 'Ergonomic Mouse',
price: 680,
category: 'Electronics',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
// GET /api/v1/products — List all products (with pagination)
router.get('/', (req, res) => {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 10));
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedProducts = products.slice(startIndex, endIndex);
const totalPages = Math.ceil(products.length / limit);
res.json({
data: paginatedProducts,
pagination: {
currentPage: page,
totalPages,
totalItems: products.length,
itemsPerPage: limit,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
},
});
});
// GET /api/v1/products/:id — Get a single product
router.get('/:id', (req, res, next) => {
const product = products.find((p) => p.id === req.params.id);
if (!product) {
const err = new Error('Product not found');
err.statusCode = 404;
return next(err);
}
res.json({ data: product });
});
// POST /api/v1/products — Create a new product
router.post('/', validateProduct, (req, res) => {
const { name, price, category } = req.body;
const newProduct = {
id: uuidv4(),
name,
price,
category: category || 'General',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
products.push(newProduct);
res.status(201).json({ data: newProduct });
});
// PUT /api/v1/products/:id — Update a product (partial update)
router.put('/:id', (req, res, next) => {
const index = products.findIndex((p) => p.id === req.params.id);
if (index === -1) {
const err = new Error('Product not found');
err.statusCode = 404;
return next(err);
}
const { name, price, category } = req.body;
// Only update fields that were sent
if (name !== undefined) products[index].name = name;
if (price !== undefined) {
if (typeof price !== 'number' || price <= 0) {
const err = new Error('Price must be a positive number');
err.statusCode = 400;
return next(err);
}
products[index].price = price;
}
if (category !== undefined) products[index].category = category;
products[index].updatedAt = new Date().toISOString();
res.json({ data: products[index] });
});
// DELETE /api/v1/products/:id — Delete a product
router.delete('/:id', (req, res, next) => {
const index = products.findIndex((p) => p.id === req.params.id);
if (index === -1) {
const err = new Error('Product not found');
err.statusCode = 404;
return next(err);
}
products.splice(index, 1);
res.status(204).send();
});
module.exports = router;
Here is what each endpoint does:
- GET / — Paginates using
pageandlimitquery parameters.limitis capped between 1 and 100 to prevent a single request from pulling 10,000 records. - GET /:id — If the product is not found, it forwards the error to the centralized handler via
next(err). - POST / — Runs
validateProductmiddleware first. On success, creates the product and returns201 Created. - PUT /:id — Updates only the fields that were sent. Automatically refreshes the
updatedAttimestamp. - DELETE /:id — Returns
204 No Contenton success — the response body is empty.
Step 5: Input Validation
Every piece of external input is untrusted. Open middleware/validate.js:
function validateProduct(req, res, next) {
const errors = [];
const { name, price } = req.body;
// name checks
if (!name || typeof name !== 'string') {
errors.push('name is required and must be a string');
} else if (name.trim().length < 2) {
errors.push('name must be at least 2 characters');
} else if (name.trim().length > 200) {
errors.push('name must not exceed 200 characters');
}
// price checks
if (price === undefined || price === null) {
errors.push('price is required');
} else if (typeof price !== 'number' || price <= 0) {
errors.push('price must be a positive number');
} else if (price > 1_000_000) {
errors.push('price must not exceed 1,000,000');
}
if (errors.length > 0) {
return res.status(400).json({ error: 'Validation failed', details: errors });
}
// Write sanitized values back to the body
req.body.name = name.trim();
next();
}
module.exports = { validateProduct };
The validation middleware handles five things:
- Checks that required fields exist.
- Verifies types (string, number).
- Enforces boundaries (min/max length, min/max value).
- Returns
400 Bad Requestwith a detailed error list if anything fails. - Calls
next()to pass control to the route handler if everything is valid.
Step 6: Centralized Error Handling
Instead of writing try-catch blocks in every route, we use a single error middleware. Open middleware/errorHandler.js:
function errorHandler(err, req, res, _next) {
const statusCode = err.statusCode || 500;
const isServerError = statusCode >= 500;
// Log server errors — do not expose details to the client
if (isServerError) {
console.error(`[${new Date().toISOString()}] ${err.stack || err.message}`);
}
res.status(statusCode).json({
error: isServerError ? 'Internal server error' : err.message,
...(process.env.NODE_ENV === 'development' && isServerError
? { stack: err.stack }
: {}),
});
}
module.exports = { errorHandler };
Why should error handling be middleware? Three reasons. First, it eliminates code duplication — you do not need the same try-catch block in every route. Second, logging and error formatting live in one place; when you need to change the format, you touch one file. Third, the error response stays consistent — an API that sometimes returns JSON and sometimes returns plain text drives frontend developers mad.
Two important distinctions in this setup:
- 4xx errors (400, 404, 409) are the client's fault. We return the error message as-is.
- 5xx errors (500) are the server's fault. We log the real message but only tell the client "Internal server error." The stack trace is hidden in production and shown in development.
Step 7: Pagination in Detail
Let us start with why pagination matters.
Why is pagination necessary? Imagine a database with 10,000 products. Sending all of them in a single request means: (1) 5-10 MB of JSON leaving the server, (2) 2-3 seconds of parsing on the client, (3) wasted bandwidth on mobile, (4) unnecessary database load. Pagination solves all of these — the client fetches only the slice it needs.
Let us revisit the pagination code from the listing endpoint:
// Page number: minimum 1
const page = Math.max(1, parseInt(req.query.page) || 1);
// Items per page: minimum 1, maximum 100
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 10));
// Array slicing
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedProducts = products.slice(startIndex, endIndex);
// Total page count
const totalPages = Math.ceil(products.length / limit);
The response structure looks like this:
{
"data": [
{ "id": "...", "name": "Mechanical Keyboard", "price": 1250 }
],
"pagination": {
"currentPage": 1,
"totalPages": 1,
"totalItems": 2,
"itemsPerPage": 10,
"hasNextPage": false,
"hasPrevPage": false
}
}
The hasNextPage and hasPrevPage booleans make it easy for the client to decide whether to show "Next" and "Previous" buttons.
Step 8: HTTP Status Codes
Returning the correct status codes makes life easier for the developer consuming your API. Here are the codes used in our project and the ones you will encounter most:
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT requests |
| 201 | Created | POST created a new resource |
| 204 | No Content | DELETE successful, response body empty |
| 400 | Bad Request | Validation error, missing or invalid field |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Resource already exists (e.g., duplicate name) |
| 500 | Internal Server Error | Unexpected server-side failure |
Bonus: CORS and Request Logging
Let us add two more useful middlewares. In index.js, you can fine-tune the CORS configuration:
// Detailed CORS config (replace app.use(cors()) in index.js)
app.use(cors({
origin: ['http://localhost:5173', 'https://yoursite.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
To add a simple request logger:
// Request logging middleware — place between cors and routes
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(
`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`
);
});
next();
});
This logger prints the method, URL, status code, and duration in milliseconds for every request. In production, use a structured logger like Winston or Pino.
Test the API
Start the server:
node index.js
Open another terminal and test every endpoint with curl:
# List all products
curl http://localhost:3000/api/v1/products | jq
# Paginated list (page 1, 1 item per page)
curl "http://localhost:3000/api/v1/products?page=1&limit=1" | jq
# Get a single product
curl http://localhost:3000/api/v1/products/550e8400-e29b-41d4-a716-446655440000 | jq
# Create a new product
curl -X POST http://localhost:3000/api/v1/products \
-H "Content-Type: application/json" \
-d '{"name": "USB-C Hub", "price": 450, "category": "Accessories"}' | jq
# Update a product (price only)
curl -X PUT http://localhost:3000/api/v1/products/550e8400-e29b-41d4-a716-446655440000 \
-H "Content-Type: application/json" \
-d '{"price": 1100}' | jq
# Delete a product
curl -X DELETE http://localhost:3000/api/v1/products/6ba7b810-9dad-11d1-80b4-00c04fd430c8 -v
# Validation error test — send empty body
curl -X POST http://localhost:3000/api/v1/products \
-H "Content-Type: application/json" \
-d '{}' | jq
# 404 test — non-existent ID
curl http://localhost:3000/api/v1/products/wrong-id | jq
Each command should return the expected status code and JSON response. jq formats the JSON output for readability — if you do not have it installed, you can use npm install -g json or read the raw output.
Next Steps
This guide used an in-memory data store. To move to production, follow these steps in order:
- Database connection. The in-memory array resets when the server restarts. Use PostgreSQL (Supabase, Neon), MongoDB, or SQLite to persist data.
- Authentication and JWT. Control who can access which endpoints. Add token-based auth with the
jsonwebtokenpackage. Protect POST, PUT, and DELETE routes. - Rate limiting. Use the
express-rate-limitpackage to cap requests per minute. This protects your API against abuse and DDoS. - API documentation. Generate Swagger/OpenAPI docs with
swagger-jsdocandswagger-ui-express. Let your frontend team discover and test endpoints interactively. - Testing. Write unit tests with Jest or Vitest and integration tests with Supertest. Cover both success and error scenarios for every CRUD endpoint.
For a Python-based project, try Build Your Own SEO Crawler.
For professional API development and software consulting, explore our services.
Frequently Asked Questions
What is the difference between REST API and GraphQL?
With REST, you define separate endpoints for each resource: /products, /products/:id, /orders. The client cannot choose which fields to receive — it gets whatever the server returns. With GraphQL, there is a single endpoint (/graphql) and the client specifies exactly which fields it needs in the query. REST is faster to set up for simple CRUD APIs and works naturally with HTTP caching. GraphQL shines in projects with deeply nested data and different client needs (mobile vs. web).
How do I add authentication to this API?
The most common approach is JWT (JSON Web Token). When a user logs in, the server generates a token. The client sends this token with every request in the Authorization: Bearer <token> header. You write an auth middleware on the server: it verifies the token, returns 401 Unauthorized if it is invalid, writes the user info to req.user if it is valid, and calls next(). Place this middleware before your POST, PUT, and DELETE routes.
How do I replace in-memory data with a database?
Replace the products array with a database table. For PostgreSQL, use the pg package or Prisma ORM. For MongoDB, use mongoose. Swap the array operations (products.find(), products.push()) with SQL queries or ORM calls. Store database credentials in a .env file and load them with the dotenv package. Everything else — the router, middleware, and error handler — stays the same.
