Building a REST API
Most modern apps are split in two. The front end (a website, an Android app, a React app) shows the screens. The back end stores the data and applies the rules. They talk through an API (Application Programming Interface): a set of URLs the front end can call to read and change data, usually in JSON.
REST is the most common style for designing such APIs. In this lesson you will learn the REST rules for URLs, methods and status codes, and then build a complete product API for an online shop with Express 5: list with filters, sorting and pages, read one, create, update and delete.
REST in plain words
REST organises an API around resources: the things your app manages, such as products, orders or students. Each resource has a URL, and the HTTP method says what to do with it.
| Method and URL | Action | Success status |
|---|---|---|
GET /api/v1/products | List products (with filters) | 200 OK |
GET /api/v1/products/2 | Read one product | 200 OK |
POST /api/v1/products | Create a product | 201 Created |
PUT /api/v1/products/2 | Replace the whole product | 200 OK |
PATCH /api/v1/products/2 | Change some fields | 200 OK |
DELETE /api/v1/products/2 | Delete it | 204 No Content |
These four basic actions are often called CRUD: Create, Read, Update, Delete. The rules that make an API easy to use:
- URLs are nouns, methods are verbs. Write
DELETE /products/2, notGET /deleteProduct?id=2. - Use plural names for collections:
/products,/orders. - Nest only for clear ownership:
/orders/105/itemslists the items of order 105. - Send and receive JSON with the
Content-Type: application/jsonheader. - Use honest status codes.
400for bad input,401for not logged in,403for not allowed,404for not found,409for a conflict such as a duplicate email,500for server bugs. - Be stateless. Each request carries everything needed to handle it (for example a login token), so any server copy can answer it.
- Version the API with a prefix such as
/api/v1, so you can release/api/v2later without breaking existing apps.
Project structure
Keep the main file short and put each resource in its own router file:
shop-api/
package.json ("type": "module", express installed)
app.js
routes/
products.js// app.js
import express from "express";
import productsRouter from "./routes/products.js";
const app = express();
app.use(express.json());
app.use("/api/v1/products", productsRouter);
app.use((req, res) => {
res.status(404).json({ error: `No route for ${req.method} ${req.path}` });
});
app.listen(3000, () => console.log("Shop API on http://localhost:3000"));The last app.use() has no path, so it catches every request that no route answered and replies with a JSON 404 instead of Express's HTML page. An API should always answer in JSON.
The products router
For now the data lives in an array in memory, so it resets when the server restarts. In Connecting to MySQL you will swap the array for a real database without changing the URLs.
// routes/products.js
import { Router } from "express";
const router = Router();
let nextId = 4;
const products = [
{ id: 1, name: "Cotton kurta", category: "clothing", price: 799 },
{ id: 2, name: "Cricket bat", category: "sports", price: 2499 },
{ id: 3, name: "Steel tiffin", category: "kitchen", price: 450 },
];
// GET /api/v1/products?category=sports&sort=price&page=1&limit=10
router.get("/", (req, res) => {
const { category, sort } = req.query;
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 10));
let result = products;
if (category) result = result.filter((p) => p.category === category);
if (sort === "price") result = result.toSorted((a, b) => a.price - b.price);
if (sort === "-price") result = result.toSorted((a, b) => b.price - a.price);
const start = (page - 1) * limit;
res.json({ page, limit, total: result.length, data: result.slice(start, start + limit) });
});
// GET /api/v1/products/2
router.get("/:id", (req, res) => {
const product = products.find((p) => p.id === Number(req.params.id));
if (!product) return res.status(404).json({ error: "Product not found" });
res.json(product);
});
// POST /api/v1/products
router.post("/", (req, res) => {
const { name, category, price } = req.body;
if (!name || typeof price !== "number") {
return res.status(400).json({ error: "name and a numeric price are required" });
}
const product = { id: nextId++, name, category: category ?? "general", price };
products.push(product);
res.status(201).location(`${req.baseUrl}/${product.id}`).json(product);
});
// PATCH /api/v1/products/2 (change some fields)
router.patch("/:id", (req, res) => {
const product = products.find((p) => p.id === Number(req.params.id));
if (!product) return res.status(404).json({ error: "Product not found" });
const { name, category, price } = req.body;
if (name !== undefined) product.name = name;
if (category !== undefined) product.category = category;
if (price !== undefined) product.price = price;
res.json(product);
});
// DELETE /api/v1/products/2
router.delete("/:id", (req, res) => {
const index = products.findIndex((p) => p.id === Number(req.params.id));
if (index === -1) return res.status(404).json({ error: "Product not found" });
products.splice(index, 1);
res.sendStatus(204);
});
export default router;Details worth noticing:
- Filtering, sorting and pagination use query parameters.
sort=-price(with a minus) is a common way to say "descending".Number(...) || 1turns a missing or invalid value such aspage=abcinto the default, andMath.min(50, ...)stops a client from asking for a million rows at once. - The list response is an object, not a bare array: it carries
page,limitandtotalso the front end can draw page buttons. toSorted()returns a sorted copy and leaves the original array unchanged (ES2023).201with aLocationheader tells the client where the new product lives.req.baseUrlis the router's mount path,/api/v1/products.PATCHchanges only the fields sent. APUTwould replace the whole product, so it should require every field.204 No Contentmeans success with no body, typical forDELETE.
Testing the API
This script calls every route in order:
// client.js
const BASE = "http://localhost:3000/api/v1/products";
async function call(method, path = "", body) {
const res = await fetch(BASE + path, {
method,
headers: body ? { "Content-Type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
console.log(`${method} ${path || "/"} -> ${res.status}`, text || "(no body)");
return res;
}
await call("GET", "?sort=-price&limit=2");
await call("GET", "?category=kitchen");
const created = await call("POST", "", { name: "Brass diya set", category: "home", price: 349 });
console.log("Location:", created.headers.get("location"));
await call("PATCH", "/4", { price: 299 });
await call("DELETE", "/4");
await call("GET", "/4");
await call("POST", "", { name: "No price" });GET ?sort=-price&limit=2 -> 200 {"page":1,"limit":2,"total":3,"data":[{"id":2,"name":"Cricket bat","category":"sports","price":2499},{"id":1,"name":"Cotton kurta","category":"clothing","price":799}]}
GET ?category=kitchen -> 200 {"page":1,"limit":10,"total":1,"data":[{"id":3,"name":"Steel tiffin","category":"kitchen","price":450}]}
POST / -> 201 {"id":4,"name":"Brass diya set","category":"home","price":349}
Location: /api/v1/products/4
PATCH /4 -> 200 {"id":4,"name":"Brass diya set","category":"home","price":299}
DELETE /4 -> 204 (no body)
GET /4 -> 404 {"error":"Product not found"}
POST / -> 400 {"error":"name and a numeric price are required"}The same calls with curl look like this, which is handy for quick checks:
curl "http://localhost:3000/api/v1/products?category=sports"
curl -X POST http://localhost:3000/api/v1/products -H "Content-Type: application/json" -d "{\"name\":\"Yoga mat\",\"price\":599}"
curl -X DELETE http://localhost:3000/api/v1/products/2 -iThe POST check here is very basic. Real APIs check every field: length, type, allowed values. You will do that properly with a schema library in Validating Input, and centralise error responses in Error Handling.
Common mistakes
- Verbs in URLs.
/api/createOrdershould bePOST /api/orders. - Always replying 200. Clients and monitoring tools rely on status codes to detect errors.
- Returning every row. Large lists must be paginated, with a maximum
limit. - Forgetting that IDs from the URL are strings.
p.id === req.params.idcompares a number with a string and never matches; convert withNumber(). - Sending HTML errors from an API. Add a JSON
404handler after your routes.
Key points
- REST APIs expose resources at noun URLs and use HTTP methods for actions:
GET,POST,PUT,PATCH,DELETE. - Use accurate status codes:
201for created,204for deleted,400,404and so on for errors. - Use query parameters for filtering, sorting and pagination, and cap the page size.
- Put each resource in its own router and mount it under a versioned prefix such as
/api/v1. - Finish the app with a JSON
404handler so every response is JSON.
Exercise
Add an orders resource to the shop API in routes/orders.js, mounted at /api/v1/orders. Support GET / (optionally filtered by ?status=pending), POST / (body: productId and qty; reply 201 with the new order, which starts with status: "pending"), and PATCH /:id that only allows changing status to "shipped" or "cancelled" (otherwise reply 400).
Show solution
// routes/orders.js
import { Router } from "express";
const router = Router();
const orders = [];
let nextId = 1;
router.get("/", (req, res) => {
const { status } = req.query;
res.json(status ? orders.filter((o) => o.status === status) : orders);
});
router.post("/", (req, res) => {
const { productId, qty } = req.body;
if (!Number.isInteger(productId) || !Number.isInteger(qty) || qty < 1) {
return res.status(400).json({ error: "productId and qty must be whole numbers" });
}
const order = { id: nextId++, productId, qty, status: "pending" };
orders.push(order);
res.status(201).json(order);
});
router.patch("/:id", (req, res) => {
const order = orders.find((o) => o.id === Number(req.params.id));
if (!order) return res.status(404).json({ error: "Order not found" });
if (!["shipped", "cancelled"].includes(req.body.status)) {
return res.status(400).json({ error: "status must be shipped or cancelled" });
}
order.status = req.body.status;
res.json(order);
});
export default router;POST / -> 201 {"id":1,"productId":2,"qty":1,"status":"pending"}
PATCH /1 -> 200 {"id":1,"productId":2,"qty":1,"status":"shipped"}
PATCH /1 -> 400 {"error":"status must be shipped or cancelled"}
GET ?status=pending -> 200 []Mount it in app.js with app.use("/api/v1/orders", ordersRouter). The PATCH route checks the new value against a list of allowed values before changing anything.
Quick Quiz
1. Which request should delete product 7 in a REST API?
REST uses the method for the action and a noun URL for the resource.
2. What status code should a successful POST that creates a new order return?
201 Created, ideally with a Location header pointing to the new resource.
3. What is the difference between PUT and PATCH?
PATCH is a partial update, PUT a full replacement.
4. Why should a list endpoint cap the limit query parameter?
Pagination with a maximum page size protects the server and keeps responses fast.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
