Lesson 8 of 107 min read

Backend with Node

Share:WhatsAppLinkedIn

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 response
  • next, 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.body is always undefined. This is the number one Express gotcha. Add app.use(express.json()) before your routes.
  • Not handling missing resources with 404. Returning a 200 with an empty body or null when 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 parseInt without a radix. parseInt("08") without a second argument 10 was parsed as octal in old engines. Always pass 10: parseInt(str, 10).

What to try next

  1. Add a GET /todos?done=true query parameter filter. Read req.query.done and, if it is "true" or "false", filter the array accordingly before sending the response.
  2. Add request validation middleware that checks every POST body has a text field. Write it as a standalone function validateTodo(req, res, next) and pass it as the second argument to your route: app.post("/todos", validateTodo, (req, res) => {...}).
  3. Replace the in-memory array with a JSON file. Use Node's fs.readFileSync to load todos on startup and fs.writeFileSync to persist changes. This means todos survive a server restart, a primitive but working persistence layer.

Test Your Knowledge

Take a quick quiz on this lesson

Start Quiz →

Prefer watching over reading?

Subscribe for free.

Subscribe on YouTube