Backend Development
Hands-onBeginner 40 min· Foundations · 4 of 20

Build an API with Node.js and Express

Build the Brew & Bean ordering API with Node.js and Express: routes, JSON bodies, validation, status codes, error handling and testing with curl. The same four endpoints appear in the FastAPI lesson.

What you will build: the Brew & Bean API with four endpoints: GET /menu, POST /orders, GET /orders/{id} and PATCH /orders/{id}/status. Orders are kept in memory for now (they vanish when the server restarts); the *SQL and PostgreSQL* and *ORMs and migrations* lessons add a real database.

Prerequisites: basic JavaScript (functions, arrays, objects, async is not needed here), the ideas from *HTTP in depth* and *Designing REST APIs*, and Node.js installed (version 20 or newer; check with node -v). Download Node.js from nodejs.org if you do not have it.

1. Set up the project

  1. 1Make a folder and move into it.
  2. 2Create package.json with npm init -y.
  3. 3Install Express with npm install express.
  4. 4Open package.json, add "type": "module" so we can use import syntax, and add the two scripts shown below.
Terminal
mkdir brew-and-bean-api
cd brew-and-bean-api
npm init -y
npm install express
mkdir src
package.json (after adding type and scripts)
{
  "name": "brew-and-bean-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js"
  }
}

npm install express also adds a dependencies section to this file; keep it. node --watch restarts the server whenever you save a file (available in Node 18.11 and newer).

This lesson uses Express 5, the current major version on npm at the time of writing. Express is a small framework: it gives you routing (which function runs for which method and path), tools to read the request and tools to build the response.

2. The menu data

src/menu.js
export const menu = [
  { id: "espresso", name: "Espresso", category: "coffee", price: 120 },
  { id: "cappuccino", name: "Cappuccino", category: "coffee", price: 180 },
  { id: "cold-brew", name: "Cold Brew", category: "coffee", price: 220 },
  { id: "filter-coffee", name: "Filter Coffee", category: "coffee", price: 80 },
  { id: "masala-chai", name: "Masala Chai", category: "tea", price: 90 },
  { id: "croissant", name: "Butter Croissant", category: "bakery", price: 150 },
  { id: "banana-bread", name: "Banana Bread", category: "bakery", price: 130 },
  { id: "veg-sandwich", name: "Veg Grilled Sandwich", category: "food", price: 160 },
];

The eight Brew & Bean items. Prices are whole rupees. export lets other files import this list.

3. A server with one route

Start with the smallest working server, then grow it. Create src/server.js:

src/server.js (first version)
import express from "express";
import { menu } from "./menu.js";

const app = express();
app.use(express.json());

app.get("/menu", (req, res) => {
  res.json(menu);
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Brew & Bean API listening on http://localhost:${PORT}`);
});

express.json() is middleware: code that runs on every request before your route. It reads a JSON body and puts it in req.body. res.json() sends JSON with the right Content-Type and status 200.

  1. 1Run npm run dev in the terminal.
  2. 2In a second terminal, call the endpoint with curl (use curl.exe in Windows PowerShell).
Terminal (second window)
curl -i http://localhost:3000/menu
What you should see
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...

[{"id":"espresso","name":"Espresso","category":"coffee","price":120}, ...]

You now have a working API. Everything else follows the same pattern: app.METHOD(path, handler), where the handler receives req (the request) and res (the response).

4. The complete server

Replace src/server.js with the full version below. Read the comments in your head as you go; the next section explains each part.

src/server.js (complete)
import express from "express";
import { menu } from "./menu.js";

const app = express();
app.use(express.json());

const orders = [];
let nextId = 1;

const NEXT_STATUS = {
  received: "preparing",
  preparing: "ready",
  ready: "picked_up",
};

function sendError(res, status, code, message, details) {
  res.status(status).json({ error: { code, message, details } });
}

app.get("/menu", (req, res) => {
  const { category } = req.query;
  const items = category ? menu.filter((m) => m.category === category) : menu;
  res.json(items);
});

app.post("/orders", (req, res) => {
  const { customer_name, phone, items, pickup_time } = req.body ?? {};
  const problems = [];

  if (typeof customer_name !== "string" || customer_name.trim() === "") {
    problems.push({ field: "customer_name", message: "Required" });
  }
  if (typeof phone !== "string" || !/^\d{10}$/.test(phone)) {
    problems.push({ field: "phone", message: "Must be a 10-digit number" });
  }
  if (!Array.isArray(items) || items.length === 0) {
    problems.push({ field: "items", message: "Provide at least one item" });
  }
  if (problems.length > 0) {
    return sendError(res, 422, "validation_error", "Invalid order.", problems);
  }

  let total = 0;
  for (const [i, line] of items.entries()) {
    const menuItem = menu.find((m) => m.id === line.item_id);
    if (!menuItem) {
      problems.push({ field: `items[${i}].item_id`, message: "Unknown item" });
    } else if (!Number.isInteger(line.qty) || line.qty < 1) {
      problems.push({ field: `items[${i}].qty`, message: "Must be a whole number, at least 1" });
    } else {
      total += menuItem.price * line.qty;
    }
  }
  if (problems.length > 0) {
    return sendError(res, 422, "validation_error", "Invalid order.", problems);
  }

  const order = {
    id: nextId++,
    customer_name: customer_name.trim(),
    phone,
    items: items.map(({ item_id, qty }) => ({ item_id, qty })),
    total,
    status: "received",
    pickup_time: pickup_time ?? null,
    created_at: new Date().toISOString(),
  };
  orders.push(order);
  res.status(201).location(`/orders/${order.id}`).json(order);
});

app.get("/orders/:id", (req, res) => {
  const order = orders.find((o) => o.id === Number(req.params.id));
  if (!order) {
    return sendError(res, 404, "not_found", "Order not found.");
  }
  res.json(order);
});

app.patch("/orders/:id/status", (req, res) => {
  const order = orders.find((o) => o.id === Number(req.params.id));
  if (!order) {
    return sendError(res, 404, "not_found", "Order not found.");
  }
  const { status } = req.body ?? {};
  if (status === "cancelled") {
    if (order.status !== "received") {
      return sendError(res, 409, "conflict", "Only a received order can be cancelled.");
    }
  } else if (NEXT_STATUS[order.status] !== status) {
    return sendError(res, 409, "conflict", `Cannot move from ${order.status} to ${status}.`);
  }
  order.status = status;
  res.json(order);
});

app.use((req, res) => {
  sendError(res, 404, "not_found", "No such route.");
});

app.use((err, req, res, next) => {
  if (err.type === "entity.parse.failed") {
    return sendError(res, 400, "bad_json", "Request body is not valid JSON.");
  }
  console.error(err);
  sendError(res, 500, "server_error", "Something went wrong.");
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Brew & Bean API listening on http://localhost:${PORT}`);
});

Orders live in the orders array and ids come from nextId. Errors all use the same JSON shape from the REST lesson.

5. How it works

Key parts of the code

PieceWhat it doesWhy
req.params.idThe :id part of /orders/:idPath parameters identify one resource
req.query.categoryThe ?category=tea part of the URLQuery parameters filter lists
req.bodyThe parsed JSON bodyOnly filled if express.json() is used and Content-Type is JSON
res.status(201)Sets the status codeCorrect codes tell clients what happened
return sendError(...)Stops the handler after sending an errorWithout return, the code would carry on and try to send a second response
NEXT_STATUSA map of the only legal status movesStops an order jumping from received to picked_up

Look at POST /orders. It never reads a price from the client. It looks each item_id up in the menu, rejects unknown items and bad quantities with 422, and adds price * qty to total itself. This is the rule from *What a backend does*: never trust the client.

Order of routes and middleware matters. Express checks them top to bottom. The catch-all app.use((req, res) => ...) that returns 404 for unknown routes sits after all real routes. The error handler (four arguments: err, req, res, next) goes last. Express 5 also passes errors from async handlers to it automatically.

6. Test every endpoint

Keep npm run dev running and try these in another terminal. In Windows PowerShell use Git Bash or write the JSON on one line with curl.exe; the Git Bash form is shown.

Place an order
curl -i -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer_name":"Asha","phone":"9876543210","items":[{"item_id":"cappuccino","qty":1},{"item_id":"croissant","qty":2}]}'
Expected
HTTP/1.1 201 Created
Location: /orders/1
Content-Type: application/json; charset=utf-8
...

{"id":1,"customer_name":"Asha","phone":"9876543210","items":[{"item_id":"cappuccino","qty":1},{"item_id":"croissant","qty":2}],"total":480,"status":"received","pickup_time":null,"created_at":"2026-..."}
Read it, then move its status
curl http://localhost:3000/orders/1

curl -X PATCH http://localhost:3000/orders/1/status \
  -H "Content-Type: application/json" \
  -d '{"status":"preparing"}'

curl -X PATCH http://localhost:3000/orders/1/status \
  -H "Content-Type: application/json" \
  -d '{"status":"picked_up"}'

The first PATCH returns the order with status preparing. The second returns 409 because you cannot skip from preparing to picked_up.

Send bad data
curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"phone":"1","items":[]}'
Expected (status 422)
{"error":{"code":"validation_error","message":"Invalid order.","details":[{"field":"customer_name","message":"Required"},{"field":"phone","message":"Must be a 10-digit number"},{"field":"items","message":"Provide at least one item"}]}}

Checklist: does it behave?

  1. GET /menu returns eight items; GET /menu?category=tea returns only Masala Chai.
  2. A valid order returns 201 with total 480 for one cappuccino and two croissants.
  3. A missing order id returns 404 with the error JSON.
  4. Bad JSON (-d '{bad') returns 400 bad_json.
  5. Moving the status out of order returns 409.

Empty body? Check the header

Do not forget Content-Type: application/json when you POST or PATCH. Without it express.json() skips the body, and you will see validation errors for fields you did send.

7. Where to go next

What this version does not have: a real database (data is lost on restart), authentication so only staff can change the status, request logging and tests. They are all coming in the Production-ready APIs module. A few improvements you can do now are in the exercises.

Prompt · Explain my Express error
I'm building a REST API with Node.js and Express 5 (ES modules). I get this error when I run `npm run dev` or call an endpoint:

---
[paste the full error and the stack trace]
---

Here is the relevant code:

---
[paste the route or the file]
---

Explain in plain English what the error means, show the smallest fix and tell me how to confirm it worked with curl.

What to expect: A good answer points to the exact line, explains the cause (for example a missing return, a wrong import path or a missing .js extension in an ES module import) and gives a curl command to verify.

Try it: Add a status filter

Add a GET /orders endpoint that lists all orders, with an optional ?status=ready filter. Return 200 with an array.

Try it: Respect the opening hours

Brew & Bean is open 8 am to 10 pm. Reject orders whose pickup_time is outside these hours with 422 and a field error on pickup_time. Hint: parse with new Date(pickup_time) and check getHours() (this uses the server's time zone, which is fine for this exercise).

Try it: Cancel an order

Add DELETE /orders/:id that cancels an order (set its status to cancelled) only when it is still received, returning 200 with the order. Otherwise return 409.

Key takeaways

  1. Express routes are app.METHOD(path, handler); the handler reads req and writes res.
  2. express.json() middleware parses JSON bodies into req.body; clients must send the JSON Content-Type header.
  3. Read path parameters from req.params and query parameters from req.query.
  4. Validate every input, compute totals on the server and return accurate status codes (201, 404, 409, 422).
  5. Return after sending an error so the handler does not send a second response.
  6. Place the 404 catch-all and the four-argument error handler after the real routes.
  7. Keep one error JSON shape across the API; test every endpoint, including failures, with curl.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why does the handler return sendError(...) instead of just calling it?

2. req.body is undefined for your POST route even though you sent JSON. What is the most likely cause?

3. Where must the catch-all 404 middleware go?

4. Which design is correct for computing an order total?

5. A staff member tries to change an order from preparing straight to picked_up. What status should the API return?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free