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
- 1Make a folder and move into it.
- 2Create
package.jsonwithnpm init -y. - 3Install Express with
npm install express. - 4Open
package.json, add"type": "module"so we can useimportsyntax, and add the two scripts shown below.
mkdir brew-and-bean-api
cd brew-and-bean-api
npm init -y
npm install express
mkdir src{
"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
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:
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.
- 1Run
npm run devin the terminal. - 2In a second terminal, call the endpoint with curl (use
curl.exein Windows PowerShell).
curl -i http://localhost:3000/menuHTTP/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.
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
| Piece | What it does | Why |
|---|---|---|
req.params.id | The :id part of /orders/:id | Path parameters identify one resource |
req.query.category | The ?category=tea part of the URL | Query parameters filter lists |
req.body | The parsed JSON body | Only filled if express.json() is used and Content-Type is JSON |
res.status(201) | Sets the status code | Correct codes tell clients what happened |
return sendError(...) | Stops the handler after sending an error | Without return, the code would carry on and try to send a second response |
NEXT_STATUS | A map of the only legal status moves | Stops 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.
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}]}'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-..."}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.
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"phone":"1","items":[]}'{"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?
GET /menureturns eight items;GET /menu?category=teareturns only Masala Chai.- A valid order returns 201 with total 480 for one cappuccino and two croissants.
- A missing order id returns 404 with the error JSON.
- Bad JSON (
-d '{bad') returns 400bad_json. - 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.
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
- Express routes are
app.METHOD(path, handler); the handler readsreqand writesres. express.json()middleware parses JSON bodies intoreq.body; clients must send the JSON Content-Type header.- Read path parameters from
req.paramsand query parameters fromreq.query. - Validate every input, compute totals on the server and return accurate status codes (201, 404, 409, 422).
- Return after sending an error so the handler does not send a second response.
- Place the 404 catch-all and the four-argument error handler after the real routes.
- 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.