Building a REST API

Node.js · Lesson 16 of 30 6 min read Updated

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 URLActionSuccess status
GET /api/v1/productsList products (with filters)200 OK
GET /api/v1/products/2Read one product200 OK
POST /api/v1/productsCreate a product201 Created
PUT /api/v1/products/2Replace the whole product200 OK
PATCH /api/v1/products/2Change some fields200 OK
DELETE /api/v1/products/2Delete it204 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, not GET /deleteProduct?id=2.
  • Use plural names for collections: /products, /orders.
  • Nest only for clear ownership: /orders/105/items lists the items of order 105.
  • Send and receive JSON with the Content-Type: application/json header.
  • Use honest status codes. 400 for bad input, 401 for not logged in, 403 for not allowed, 404 for not found, 409 for a conflict such as a duplicate email, 500 for 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/v2 later without breaking existing apps.

Project structure

Keep the main file short and put each resource in its own router file:

Text
shop-api/
  package.json        ("type": "module", express installed)
  app.js
  routes/
    products.js
JavaScript
// 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.

JavaScript
// 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(...) || 1 turns a missing or invalid value such as page=abc into the default, and Math.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, limit and total so the front end can draw page buttons.
  • toSorted() returns a sorted copy and leaves the original array unchanged (ES2023).
  • 201 with a Location header tells the client where the new product lives. req.baseUrl is the router's mount path, /api/v1/products.
  • PATCH changes only the fields sent. A PUT would replace the whole product, so it should require every field.
  • 204 No Content means success with no body, typical for DELETE.

Testing the API

This script calls every route in order:

JavaScript
// 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" });
Output
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:

Terminal
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 -i

The 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/createOrder should be POST /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.id compares a number with a string and never matches; convert with Number().
  • Sending HTML errors from an API. Add a JSON 404 handler 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: 201 for created, 204 for deleted, 400, 404 and 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 404 handler 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
JavaScript
// 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;
Output
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?

2. What status code should a successful POST that creates a new order return?

3. What is the difference between PUT and PATCH?

4. Why should a list endpoint cap the limit query parameter?

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