Building APIs with Express

Difficulty

Answer:

Raw http module — everything is manual:

const http = require('http');
http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/users') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(users));
  } else {
    res.writeHead(404);
    res.end('Not found');
  }
}).listen(3000);

You hand-roll routing, method checks, body parsing, headers, and content types.

Express — a thin framework over http:

const express = require('express');
const app = express();
app.use(express.json());                 // body parsing

app.get('/users', (req, res) => res.json(users));
app.get('/users/:id', (req, res) => res.json(findUser(req.params.id)));

app.listen(3000);

What Express provides:

  • Routing — match method + path patterns, with params (/users/:id) and routers for modular structure.
  • Middleware pipeline — compose cross-cutting concerns (logging, auth, parsing, CORS).
  • Request/response helpersreq.params, req.query, req.body, res.json(), res.status(), res.redirect().
  • Static files & views, and a huge middleware ecosystem (helmet, morgan, multer, ...).

Interview point: Express doesn't replace http — it wraps it, giving structure and convenience while still ultimately using the same server. Alternatives (Fastify, Koa, NestJS) make different trade-offs around performance, async design, and structure.

Answer: Middleware is the core abstraction in Express — a function that runs during the request/response cycle.

Signature and flow:

function logger(req, res, next) {
  console.log(`${req.method} ${req.url}`);
  next(); // pass control to the next middleware/route
}
app.use(logger);

Each middleware can:

  • run code / modify req or res (e.g., attach req.user),
  • end the cycle by sending a response, or
  • call next() to continue, or next(err) to jump to error handling.

Execution order = registration order:

app.use(express.json());        // 1. parse JSON body
app.use(requestLogger);         // 2. log
app.use('/api', authenticate);  // 3. auth for /api routes
app.get('/api/me', handler);    // 4. route handler
app.use(errorHandler);          // 5. error middleware (last)

If express.json() were registered after the route, req.body would be undefined in the handler — a classic ordering bug.

Types of middleware:

  • Application-levelapp.use(fn).
  • Router-level — mounted on an express.Router().
  • Path-scopedapp.use('/admin', fn) runs only for matching paths.
  • Built-inexpress.json(), express.static().
  • Third-partyhelmet, cors, morgan.
  • Error-handling — four args (err, req, res, next).

Key idea: it's a pipeline — control flows top to bottom until something sends a response or calls next(err).

Answer: Express distinguishes error middleware from normal middleware by its arity: it takes four arguments.

// Registered AFTER all routes
app.use((err, req, res, next) => {
  console.error(err.stack);
  const status = err.statusCode || 500;
  res.status(status).json({ error: err.message || 'Internal Server Error' });
});

How Express reaches it:

  • You call next(err) from any middleware/route.
  • A synchronous throw in a handler is caught by Express and routed here automatically.
  • Async rejections: in Express 5, a rejected promise from an async handler is forwarded automatically. In Express 4, it is not — you must catch and next(err) yourself, often via a wrapper:
const asyncHandler = fn => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await db.getUser(req.params.id); // rejection → next(err)
  res.json(user);
}));

Best practices:

  • Keep a single central error handler; don't scatter response formatting.
  • Log full details server-side, but return a safe, generic message to clients (don't leak stack traces or internals in production).
  • Combine with custom error classes carrying statusCode so the handler maps errors to the right HTTP code.
  • Add a catch-all 404 handler before the error handler for unmatched routes.

Answer:

Basic routing — method + path → handler:

app.get('/users', listUsers);
app.post('/users', createUser);
app.put('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);

Route parameters and query strings:

// GET /users/42?fields=name
app.get('/users/:id', (req, res) => {
  req.params.id;         // '42'  (path parameter)
  req.query.fields;      // 'name' (query string)
});

Modular routers — group related routes and mount them:

// routes/users.js
const router = require('express').Router();
router.get('/', listUsers);       // GET /users
router.get('/:id', getUser);      // GET /users/:id
router.post('/', createUser);     // POST /users
module.exports = router;

// app.js
app.use('/users', require('./routes/users'));

This keeps a growing app organized — one router file per resource — and lets you attach router-level middleware (e.g., auth for all /admin routes).

Useful details:

  • Routes are matched in order; put more specific routes before catch-alls.
  • app.route('/users').get(...).post(...) chains methods for one path.
  • Patterns support optional segments and (in Express 4) regex; Express 5 changed some pattern syntax.
  • A final app.use((req, res) => res.status(404).json({error: 'Not found'})) handles unmatched routes.

Answer:

Resources as nouns, methods as verbs:

GET    /users          # list
POST   /users          # create
GET    /users/:id      # read one
PUT    /users/:id      # replace
PATCH  /users/:id      # partial update
DELETE /users/:id      # delete
GET    /users/:id/orders   # nested/related resource

Avoid verbs in paths (/getUsers, /createUser) — the HTTP method conveys the action.

Meaningful status codes:

  • 200 OK, 201 Created, 204 No Content
  • 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable Entity
  • 500 Internal Server Error, 503 Service Unavailable

Collections need pagination/filtering/sorting:

GET /users?page=2&limit=20&sort=-createdAt&role=admin

Never return unbounded lists — page them.

Other essentials:

  • Versioning/api/v1/... (or a header) so you can evolve without breaking clients.
  • Statelessness — each request carries its own auth (e.g., a bearer token); no server-side session affinity, which makes horizontal scaling trivial.
  • Consistent error shape — e.g., { "error": { "code": "...", "message": "..." } } everywhere.
  • Validation at the boundary (e.g., zod, joi) — reject bad input with 400/422 before it reaches business logic.
  • IdempotencyPUT/DELETE should be safe to retry; consider idempotency keys for POST.
  • Security — auth/authz, rate limiting, HTTPS, and don't leak internal details in errors.