Quick Answer

Express maps HTTP methods and paths to functions. Add express.json() or req.body is undefined, return meaningful status codes, and put your error handler last with four parameters or it will not run.

A working server in ten lines

npm init -y
npm install express
const express = require("express");
const app = express();

app.use(express.json());        // parse JSON request bodies

app.get("/api/health", (req, res) => {
  res.json({ status: "ok" });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`listening on ${PORT}`));

That is a real HTTP server. app.get registers a handler for GET requests to that path; res.json sends a JSON response with the right content type.

Note the port coming from process.env.PORT with a fallback. Hosting platforms assign the port through that variable, and hardcoding 3000 is the most common reason a deployed Node app never receives traffic — see deploying your project for free.

Routes and parameters

let students = [{ id: 1, name: "Asha", marks: 91 }];

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

app.get("/api/students/:id", (req, res) => {
  const student = students.find(s => s.id === Number(req.params.id));
  if (!student) return res.status(404).json({ error: "not found" });
  res.json(student);
});

app.post("/api/students", (req, res) => {
  const { name, marks } = req.body;
  if (!name) return res.status(400).json({ error: "name is required" });
  const student = { id: students.length + 1, name, marks };
  students.push(student);
  res.status(201).json(student);
});

Two details that cause real bugs. req.params.id is always a string, so s.id === req.params.id is always false — hence the Number(). And req.body is undefined without express.json() registered first, which produces a confusing "cannot destructure property of undefined".

Note the return before each error response. Without it, execution continues and Express throws "Cannot set headers after they are sent" — one of the most common Express errors and almost always this.

Status codes are part of the API

Returning 200 with { error: "not found" } is a common beginner habit and it makes the API hard to consume, because clients must parse the body to know whether it worked.

  • 200 OK · 201 Created, for a successful POST
  • 400 Bad request — the client sent something invalid
  • 401 Not authenticated · 403 Authenticated but not allowed
  • 404 Not found · 409 Conflict, such as a duplicate
  • 500 Something broke on the server

The 4xx versus 5xx distinction matters: 4xx means the client made a mistake and repeating the request unchanged will fail again; 5xx means the server failed and a retry might work.

Middleware is the core idea

A middleware function runs before your handlers and either responds or calls next() to continue:

app.use((req, res, next) => {
  console.log(`${req.method} ${req.path}`);
  next();                       // forget this and the request hangs
});

function requireAuth(req, res, next) {
  const token = req.headers.authorization;
  if (!token) return res.status(401).json({ error: "unauthorized" });
  next();
}

app.get("/api/admin", requireAuth, (req, res) => {
  res.json({ secret: true });
});

Everything is middleware — express.json(), logging, authentication, CORS. Forgetting next() means the request never completes and the client waits until it times out, with no error anywhere. That silence makes it a nasty first bug.

Order matters. Middleware runs in the order registered, so express.json() must come before any route reading req.body.

Error handling, including the async trap

An error handler has four parameters, and Express identifies it by that signature alone:

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "something went wrong" });
});

Register it last, after all routes. Three parameters instead of four and it is treated as ordinary middleware and never catches anything.

The trap: in Express 4, errors thrown inside an async handler are not caught by this. The promise rejects, nothing handles it, and the request hangs. Either wrap async handlers in try/catch and call next(err), or use a small wrapper:

const wrap = fn => (req, res, next) => fn(req, res, next).catch(next);

app.get("/api/data", wrap(async (req, res) => {
  const data = await loadData();
  res.json(data);
}));

Express 5 handles async rejections automatically, so check which version you are on. Finally, never send the raw error message to clients in production — it can leak file paths and query structure.

Frequently Asked Questions

Why is req.body undefined? You have not registered a body parser. Add app.use(express.json()) before your routes, and make sure the client is sending a Content-Type of application/json.
What does 'Cannot set headers after they are sent' mean? You sent two responses for one request, usually because an early error response was missing a return statement so execution continued into the normal response.
Why does my request hang with no response? A middleware did not call next() and did not send a response. The request sits waiting until it times out, with nothing logged, which is why this one is hard to spot.
Should I use Express or something newer? Express remains the most widely used Node framework and the best documented for learning. Fastify and others offer better performance, and the concepts transfer directly once you know Express.
How do I connect a database? Install the driver or ODM for your database, connect at startup, and use it inside route handlers. Keep credentials in environment variables rather than in code, and never commit them.