Just added: Algorithms you can run and practice
Updated Jul 10, 2026 Edit on GitHub

Request & Response

The whole job of a route handler, stripped of ceremony: it reads from req and writes through res. Input arrives on the request object - URL params, query string, parsed body, headers. You do something with it, then reach for the response object and send exactly one answer back: a status code and usually some JSON.

A handler is a small machine with one input port (req) and one output port (res). Everything in this phase is just more knobs on those two ports.

📝 One thing that trips up everyone once: req.body does not exist by default. It only gets populated if a body-parsing middleware ran first - express.json() from Phase 3: Middleware. No parser, no req.body. Hold that thought; we'll hit it again with code.

Reading the request

The req object carries four sources of input. You'll use all four constantly, so let's name them plainly.

import express from 'express';

const app = express();
app.use(express.json()); // so req.body works for JSON requests

app.post('/tasks/:id', (req, res) => {
  console.log(req.params);  // { id: '42' }  ← from the URL path
  console.log(req.query);   // { sort: 'date' } ← from ?sort=date
  console.log(req.body);    // { title: 'Buy milk' } ← parsed JSON body
  console.log(req.method);  // 'POST'
  console.log(req.path);    // '/tasks/42'
  console.log(req.get('Authorization')); // 'Bearer abc...' ← a header

  res.json({ ok: true });
});

app.listen(3000, () => console.log('http://localhost:3000'));

What just happened: the same request handed us input through four doors. req.params holds the named pieces of the route pattern (:id became '42'). req.query holds everything after the ?. req.body holds the parsed body - but only because express.json() ran first. req.headers is the raw header object; req.get('Authorization') is the case-insensitive, one-header shortcut. Note req.params.id is the string '42', not the number 42 - everything from params and query is text. Convert when you need a number.

⚠️ If you forget app.use(express.json()) and then read req.body, you won't get an error - you'll get undefined. That silent undefined is the single most common "why is my POST broken" moment in Express. When req.body is empty and you swear you sent a body, check the parser first.

Writing the response

Now the output port. The res object is how you reply, and the methods you'll live in are small.

app.get('/tasks/:id', (req, res) => {
  const task = { id: req.params.id, title: 'Buy milk', done: false };

  res.json(task); // sends JSON, sets Content-Type: application/json
});

app.post('/tasks', (req, res) => {
  const task = { id: '7', title: req.body.title };

  res.status(201).json(task); // chain: set the status, then send the body
});

app.delete('/tasks/:id', (req, res) => {
  // ... delete it ...
  res.sendStatus(204); // 204 No Content - empty body, status only
});

What just happened: res.json(obj) serializes an object to JSON and sets Content-Type for you - the workhorse. res.status(code) sets the status code and returns res, so you can chain it: res.status(201).json(task). res.sendStatus(204) sends a status with an empty body - perfect for a successful delete. A few more you'll meet: res.set('X-Foo', 'bar') for a custom header, res.send(...) for text/HTML/buffers, res.redirect(url) for a redirect.

⚠️ Send exactly one response per request. Each req/res pair gets one reply, and once you've sent it the headers are flushed. Call res.json() (or any send) a second time and Express throws Error: Cannot set headers after they are sent to the client. This usually happens when you forget a return after an early response:

if (!task) {
  res.status(404).json({ error: 'Not found' });
  // forgot `return` here ↓ - code keeps running and sends again
}
res.json(task); // 💥 headers already sent

💡 The fix is a habit: return res.status(404).json(...). Returning the response ends the handler right there.

Choosing correct status codes

The status code is a promise to the client about what happened. Lying with 200 OK on a failure makes every consumer of your API guess. Use the codes that match reality:

  • 200 OK - the standard "here's your data" success (a GET that found something).
  • 201 Created - you created a resource (a successful POST). Often paired with the new object in the body.
  • 204 No Content - success, but there's nothing to send back (a DELETE).
  • 400 Bad Request - the client sent something wrong (missing or invalid input). This is their fault.
  • 404 Not Found - the thing they asked for doesn't exist.

💡 Rough rule of thumb: 2xx means "it worked," 4xx means "you (the client) messed up," 5xx means "I (the server) messed up." Reaching for the accurate code costs you nothing and saves whoever calls your API hours of confusion.

Never trust the input - validate it

Express has no built-in validation. None. It happily hands you whatever the client sent, including nothing, garbage, or hostile junk. That's not a gap to apologize for - it's the minimalist philosophy. But it means validation is your job, and skipping it is how APIs crash on a missing field or save nonsense to the database.

The simplest approach is a manual guard at the top of the handler: check what you require, and bail early with 400 if it's missing.

app.post('/tasks', (req, res) => {
  const { title } = req.body ?? {}; // ?? {} guards against body being undefined

  if (typeof title !== 'string' || title.trim() === '') {
    return res.status(400).json({ error: 'title is required and must be a non-empty string' });
  }

  const task = { id: '7', title: title.trim(), done: false };
  res.status(201).json(task);
});

What just happened: before trusting title for anything, we checked it - pulled it from req.body (defaulting to {} so a missing body doesn't crash us), confirmed it's a non-empty string, and returned a 400 with a specific message if not. Only past that guard do we build the task. The early return does double duty: it sends one response and stops the handler.

For one or two fields, a hand-written guard is clear and readable. As rules grow (optional fields, types, lengths, nested objects), reach for a library: express-validator layers validation onto the request, or a schema library like zod or joi lets you declare the shape once and validate against it.

⚠️ The rule never bends: never trust client input. Validate before you read it, save it, or pass it anywhere. Anyone can send any bytes to your endpoint - assume someone will.

Recap

  • A handler reads from req (params, query, body, headers/req.get()) and writes through res (status + body).
  • req.body only exists if a body parser like express.json() ran first - otherwise it's undefined, silently.
  • Reply with res.json(obj), set the code with the chainable res.status(code).json(obj), and use res.sendStatus(204) for empty successes.
  • Send exactly one response per request - a second send throws "Cannot set headers after they are sent." Habitually return your responses.
  • Pick correct status codes: 201 created, 400 bad input, 404 not found, 204 no content.
  • Express has no built-in validation. Guard required input by hand (return 400) or use express-validator/zod - and never trust the client.

Quick check

[
  {
    "q": "You POST JSON to an Express route and read req.body, but it's undefined. What's the most likely cause?",
    "choices": ["The client didn't send a body", "You forgot app.use(express.json()) so no body parser ran", "req.body was renamed to req.data", "Express only parses bodies on GET requests"],
    "answer": 1,
    "explain": "req.body is only populated if a body-parsing middleware like express.json() ran first. Without it, req.body is silently undefined."
  },
  {
    "q": "Which line sends a '201 Created' response with the new task as JSON?",
    "choices": ["res.json(task).status(201)", "res.status(201).json(task)", "res.sendStatus(201, task)", "res.created(task)"],
    "answer": 1,
    "explain": "res.status(code) returns res so it's chainable: set the status first, then send the body with res.json(task)."
  },
  {
    "q": "Your handler validates input and returns a 400 if a field is missing, but you still get 'Cannot set headers after they are sent.' What's the fix?",
    "choices": ["Call res.json() twice on purpose", "Use return res.status(400).json(...) so the handler stops after responding", "Add a second express.json() middleware", "Switch the status code to 200"],
    "answer": 1,
    "explain": "Without return, code keeps running after the early response and sends a second one. Returning the response ends the handler so only one response is sent."
  }
]

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. You POST JSON to an Express route and read req.body, but it's undefined. What's the most likely cause?

2. Which line sends a '201 Created' response with the new task as JSON?

3. Your handler validates input and returns a 400 if a field is missing, but you still get 'Cannot set headers after they are sent.' What's the fix?