LearnFrenzy
Sign in Take a free test

Building a REST API with Node.js · Lesson 12 of 30

Express Middleware for Production REST APIs

5 min read Updated Nov 8, 2026 Saurabh Samir
On this page
  1. The stack, in code
  2. Practice
  3. Summary

In Express, almost everything is middleware: functions that run in order for each request, can read and change the request and response, and either respond or pass control on with next(). A production API is mostly a well-ordered stack of small middleware: correlation ids, logging, security headers, body limits, authentication, routes, and error handling.

In this lesson

  • How the middleware chain works
  • Request ids and access logs
  • Security headers and body size limits
  • 404 and error handlers, in the right order

The order of a production stack

  1. requestIdtag every request so logs can be correlated
  2. access logrecord method, path, status, time when the response finishes
  3. helmetsecure HTTP headers
  4. express.json({ limit })parse bodies, reject huge ones
  5. routesauth, validation, handlers
  6. 404 handlernothing matched
  7. error handler(err, req, res, next) – always last

The stack, in code

// server.js
import express from 'express';
import helmet from 'helmet';
import { randomUUID } from 'node:crypto';

const app = express();
app.disable('x-powered-by');

// 1. correlation id: reuse the caller's, or create one
app.use((req, res, next) => {
  req.id = req.get('X-Request-Id') ?? randomUUID();
  res.set('X-Request-Id', req.id);
  next();
});

// 2. access log, written when the response is finished
app.use((req, res, next) => {
  res.on('finish', () => console.log(`[${req.id}] ${req.method} ${req.originalUrl} → ${res.statusCode}`));
  next();
});

// 3. security headers (Content-Security-Policy, X-Content-Type-Options, …)
app.use(helmet());

// 4. JSON bodies, with a size limit
app.use(express.json({ limit: '10kb' }));

// 5. routes
app.post('/api/notes', (req, res) => res.status(201).json({ id: 1, text: req.body.text }));
app.get('/api/slow-dependency', async () => {
  throw new Error('Inventory service timed out');                 // async errors reach the handler in Express 5
});

// 6. nothing matched
app.use((req, res) => res.status(404).json({ error: 'Not found', requestId: req.id }));

// 7. errors – four arguments, registered last
app.use((err, req, res, next) => {
  const status = err.status ?? err.statusCode ?? 500;              // body-parser sets 400 / 413
  if (status >= 500) console.error(`[${req.id}] ${err.message}`);
  res.status(status).json({ error: status >= 500 ? 'Internal server error' : err.message, requestId: req.id });
});

app.listen(process.env.PORT ?? 3000);

Real responses and the server log

$ curl -X POST http://localhost:3000/api/notes -H 'Content-Type: application/json' -H 'X-Request-Id: req-1001' -d '{"text":"hello"}'
HTTP/1.1 201 Created
X-Request-Id: req-1001
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8

{
  "id": 1,
  "text": "hello"
}

$ curl -X POST http://localhost:3000/api/notes -H 'Content-Type: application/json' -H 'X-Request-Id: req-1002' -d "{\"text\":\"$(printf 'x%.0s' $(seq 1 11000))\"}"
HTTP/1.1 413 Payload Too Large
X-Request-Id: req-1002
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8

{
  "error": "request entity too large",
  "requestId": "req-1002"
}

$ curl http://localhost:3000/api/slow-dependency -H 'X-Request-Id: req-1003'
HTTP/1.1 500 Internal Server Error
X-Request-Id: req-1003
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8

{
  "error": "Internal server error",
  "requestId": "req-1003"
}

$ curl http://localhost:3000/api/nope -H 'X-Request-Id: req-1004'
HTTP/1.1 404 Not Found
X-Request-Id: req-1004
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8

{
  "error": "Not found",
  "requestId": "req-1004"
}

Server log:
  [req-1001] POST /api/notes → 201
  [req-1002] POST /api/notes → 413
  [req-1003] Inventory service timed out
  [req-1003] GET /api/slow-dependency → 500
  [req-1004] GET /api/nope → 404
  • The request id appears in the response header, the error body and every log line – when a customer reports a problem, their request id finds the exact logs.
  • helmet added headers such as X-Content-Type-Options: nosniff and Strict-Transport-Security.
  • The oversized body was rejected with 413 Payload Too Large before reaching the route – limits protect memory.
  • The thrown async error became a clean 500; the real message only went to the log.

Order matters

Middleware runs in the order it's registered. The error handler (four parameters) must come after all routes; the 404 handler after the routes but before the error handler; request ids and logging first, so even failing requests are logged.

Practice

Try it yourself

  1. Write a middleware that rejects requests without an X-Api-Key header of demo-key with 401, and apply it only to /api/admin.

    Show solution
    // server.js
    import express from 'express';
    
    const app = express();
    
    function requireApiKey(req, res, next) {
      if (req.get('X-Api-Key') !== 'demo-key') return res.status(401).json({ error: 'Invalid or missing API key' });
      next();
    }
    
    app.get('/api/public', (req, res) => res.json({ ok: true }));
    app.use('/api/admin', requireApiKey);                       // only for this path
    app.get('/api/admin/stats', (req, res) => res.json({ users: 1240 }));
    
    app.listen(process.env.PORT ?? 3000);

    Real responses

    $ curl http://localhost:3000/api/admin/stats
    HTTP/1.1 401 Unauthorized
    Content-Type: application/json; charset=utf-8
    
    {
      "error": "Invalid or missing API key"
    }
    
    $ curl http://localhost:3000/api/admin/stats -H 'X-Api-Key: demo-key'
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    
    {
      "users": 1240
    }

Quick check

How does Express recognise an error handler?

It has four parameters: (err, req, res, next).

Why log in res.on('finish')?

So the log has the final status code.

What does express.json({ limit }) protect against?

Huge bodies exhausting memory – oversized requests get 413.

Summary

  • Middleware runs in order; each either responds or calls next().
  • Production basics: request ids, access logs, helmet, body limits, 404 and error handlers.
  • Put the error handler last and never leak internal errors to clients.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Popular