Backend with Node
What you'll build
By the end of this lesson you will have a fully working REST API for a to-do list, GET /todos, POST /todos, PATCH /todos/:id, and DELETE /todos/:id. You will test it with curl and connect it to the front-end from earlier lessons. You will understand how routing, middleware, and request/response work in Express before you ever touch a framework like Next.js.
Concepts
Node as a server runtime
In lesson 6 you used Node to run build tools. Here you use it to run a web server. Node's built-in http module can create a server, but it is bare-bones:
const http = require("http");
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello from Node!");
});
server.listen(3001, () => {
console.log("Server running on http://localhost:3001");
});
This works, but every route, header, and body parsing is manual. Express wraps this with a clean API.
Express: routing and middleware
Express is a minimal web framework. You define routes by calling app.get(), app.post(), app.patch(), app.delete(), one for each HTTP method.
const express = require("express");
const app = express();
// Middleware: parse JSON request bodies
app.use(express.json());
// Route: GET /
app.get("/", (req, res) => {
res.json({ message: "API is running" });
});
// Route: GET /hello/:name, :name is a URL parameter
app.get("/hello/:name", (req, res) => {
const { name } = req.params;
res.json({ greeting: `Hello, ${name}!` });
});
app.listen(3001, () => {
console.log("Listening on http://localhost:3001");
});
Middleware are functions that run before your route handler. express.json() is middleware that reads the request body, parses it as JSON, and puts the result on req.body. Without it, req.body is undefined even when the client sends JSON.
The signature of any middleware or route handler is (req, res, next):
req, the incoming request (URL, params, headers, body)res, the outgoing responsenext, call this to pass control to the next middleware
// Custom logging middleware
function logger(req, res, next) {
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
next(); // MUST call next() or the request hangs
}
app.use(logger); // register before routes
Request and response
// Reading from the request
app.post("/echo", (req, res) => {
const body = req.body; // parsed JSON body (needs express.json())
const id = req.params.id; // URL segment: /resource/:id
const search = req.query.q; // query string: /resource?q=hello
const token = req.headers["authorization"]; // request header
res.json({ received: body }); // send JSON response (sets Content-Type automatically)
});
// Setting response status
app.get("/not-found", (req, res) => {
res.status(404).json({ error: "Resource not found" });
});
app.post("/created", (req, res) => {
res.status(201).json({ id: 1, ...req.body });
});
REST conventions
REST (Representational State Transfer) is a set of conventions for designing APIs. For a resource like todos:
| Method | Path | Action |
|---|---|---|
| GET | /todos | Return all todos |
| GET | /todos/:id | Return one todo |
| POST | /todos | Create a new todo |
| PATCH | /todos/:id | Partially update an existing todo |
| DELETE | /todos/:id | Delete a todo |
PUT replaces the entire resource; PATCH updates only the fields you send. In practice, most APIs use PATCH for updates.
CORS
When your front-end (running on localhost:5173) calls your API (running on localhost:3001), the browser blocks the request because the origins differ. You need to add CORS headers.
const cors = require("cors");
// Allow all origins (fine for development)
app.use(cors());
// Or be specific for production
app.use(cors({ origin: "https://yourapp.com" }));
Install with: npm install cors
Hands-on
Build the to-do REST API. Create a new folder and initialise a project:
mkdir todo-api && cd todo-api
npm init -y
npm install express cors
Create server.js:
const express = require("express");
const cors = require("cors");
const app = express();
const PORT = 3001;
// ---- Middleware ----
app.use(cors());
app.use(express.json());
// ---- In-memory data store ----
// In a real app this would be a database. For now, an array in memory.
let todos = [
{ id: 1, text: "Learn Express", done: false },
{ id: 2, text: "Build an API", done: false },
];
let nextId = 3;
// ---- Routes ----
// GET /todos, return all todos
app.get("/todos", (req, res) => {
res.json(todos);
});
// GET /todos/:id, return one todo
app.get("/todos/:id", (req, res) => {
const id = parseInt(req.params.id, 10);
const todo = todos.find((t) => t.id === id);
if (!todo) {
return res.status(404).json({ error: "Todo not found" });
}
res.json(todo);
});
// POST /todos, create a new todo
app.post("/todos", (req, res) => {
const { text } = req.body;
if (!text || typeof text !== "string" || text.trim() === "") {
return res.status(400).json({ error: "text is required and must be a non-empty string" });
}
const newTodo = { id: nextId++, text: text.trim(), done: false };
todos.push(newTodo);
res.status(201).json(newTodo); // 201 Created
});
// PATCH /todos/:id, update a todo (text or done)
app.patch("/todos/:id", (req, res) => {
const id = parseInt(req.params.id, 10);
const index = todos.findIndex((t) => t.id === id);
if (index === -1) {
return res.status(404).json({ error: "Todo not found" });
}
const { text, done } = req.body;
if (text !== undefined) todos[index].text = text.trim();
if (done !== undefined) todos[index].done = Boolean(done);
res.json(todos[index]);
});
// DELETE /todos/:id, delete a todo
app.delete("/todos/:id", (req, res) => {
const id = parseInt(req.params.id, 10);
const before = todos.length;
todos = todos.filter((t) => t.id !== id);
if (todos.length === before) {
return res.status(404).json({ error: "Todo not found" });
}
res.status(204).send(); // 204 No Content, success, nothing to return
});
// ---- Start ----
app.listen(PORT, () => {
console.log(`Todo API running on http://localhost:${PORT}`);
});
Start it: node server.js
Now test every endpoint with curl:
# List all todos
curl http://localhost:3001/todos
# Get one todo
curl http://localhost:3001/todos/1
# Create a new todo
curl -X POST http://localhost:3001/todos \
-H "Content-Type: application/json" \
-d '{"text": "Deploy the app"}'
# Mark todo 1 as done
curl -X PATCH http://localhost:3001/todos/1 \
-H "Content-Type: application/json" \
-d '{"done": true}'
# Delete todo 2
curl -X DELETE http://localhost:3001/todos/2
# Try a todo that does not exist
curl http://localhost:3001/todos/999
Each command prints the JSON response. The delete returns nothing (204 No Content). The missing id returns {"error": "Todo not found"} with a 404 status.
To connect your front-end from lesson 7, change the fetch URL in the React app to http://localhost:3001/todos and run both the API and the front-end dev server simultaneously in separate terminals.
Common pitfalls
- Forgetting
express.json()middleware. Without it,req.bodyis alwaysundefined. This is the number one Express gotcha. Addapp.use(express.json())before your routes. - Not handling missing resources with 404. Returning a 200 with an empty body or
nullwhen a resource is not found breaks client-side code that expects data. Always return the correct status code. - Mutating the in-memory array while iterating.
todos.splice()inside a.forEach()causes bugs. Use.filter()which returns a new array, then reassign the variable. - Not adding CORS middleware before routes. If CORS middleware is added after a route, requests to that route still fail. Always put
app.use(cors())before your route definitions. - Using
parseIntwithout a radix.parseInt("08")without a second argument10was parsed as octal in old engines. Always pass10:parseInt(str, 10).
What to try next
- Add a
GET /todos?done=truequery parameter filter. Readreq.query.doneand, if it is"true"or"false", filter the array accordingly before sending the response. - Add request validation middleware that checks every POST body has a
textfield. Write it as a standalone functionvalidateTodo(req, res, next)and pass it as the second argument to your route:app.post("/todos", validateTodo, (req, res) => {...}). - Replace the in-memory array with a JSON file. Use Node's
fs.readFileSyncto load todos on startup andfs.writeFileSyncto persist changes. This means todos survive a server restart, a primitive but working persistence layer.
Prefer watching over reading?
Subscribe for free.