Guide

Express fundamentals explained

A payment provider POSTs a webhook to your server. Before your handler runs, Express walks a middleware chain: verify the HMAC signature, parse the JSON body, attach a correlation ID, then finally call your route function. That pipeline is Express in one sentence — a thin, unopinionated layer on top of Node’s http module that maps URLs to functions and composes cross-cutting logic through ordered middleware. It is not a full framework like Django or NestJS; it gives you routing, request/response helpers, and a middleware contract. Everything else — validation, ORM, auth, OpenAPI — you choose from npm. That flexibility made Express the default Node HTTP stack for a decade; it still powers countless APIs, BFFs, and webhook receivers. This guide covers routing and Router modules, the middleware model, body parsing and static files, centralized error handling, TypeScript ergonomics, testing with supertest, a Harbor Payments webhook worked example, a framework decision table, common pitfalls, and a production checklist.

What Express is (and how it relates to Node)

Express is a web framework for Node.js. You create an application instance (express()), register middleware and routes, then call app.listen(port). Under the hood Express wraps Node’s http.createServer and normalizes incoming IncomingMessage objects into a richer req and res with convenience methods like res.json(), res.status(), and req.get('authorization').

Express 4.x (current stable line) removed built-in Connect middleware; body parsing, CORS, and security headers now come from separate packages (express.json() is bundled since 4.16). Express 5.x tightens path matching and modernizes error handling — check release notes before upgrading legacy codebases. Express does not prescribe project structure; a 200-line single file and a layered service architecture are both valid. The cost of that freedom is discipline: without conventions, route files sprawl and middleware order bugs hide until production.

Runtime stack placement

  • Node.js — executes JavaScript, event loop, built-in http.
  • Express — routing + middleware composition on http.
  • Add-ons — Prisma/Drizzle for data, Passport/JWT for auth, Zod for validation, Pino for logging.
  • Alternatives — Fastify (faster, schema-first), Hono (edge-friendly), NestJS (opinionated DI on Express/Fastify).

Routing and HTTP verbs

Routes map HTTP method + path patterns to handler functions. Express supports all common verbs and path parameters:

const app = express();

app.get('/health', (_req, res) => {
  res.json({ status: 'ok' });
});

app.get('/orders/:id', (req, res, next) => {
  const { id } = req.params;
  findOrder(id)
    .then(order => order ? res.json(order) : res.status(404).end())
    .catch(next);
});

app.post('/orders', (req, res, next) => {
  createOrder(req.body)
    .then(order => res.status(201).json(order))
    .catch(next);
});

Path patterns support named parameters (:id), optional groups, and regular expressions. Route order matters: more specific paths must register before catch-alls. A app.use('*', notFoundHandler) at the end handles unmatched routes. For larger APIs, split routes with express.Router() — mount at a prefix:

const ordersRouter = express.Router();

ordersRouter.get('/', listOrders);
ordersRouter.post('/', createOrder);
ordersRouter.get('/:id', getOrder);

app.use('/api/v1/orders', authenticate, ordersRouter);

Mounting applies shared middleware only to that subtree — cleaner than repeating authenticate on every route. Follow REST naming conventions: plural nouns, correct status codes (201 on create, 204 on delete), and consistent error JSON shapes.

The middleware chain

Middleware functions have the signature (req, res, next) => void. They run in registration order. Call next() to pass control to the next layer; call next(err) to jump to error handlers; send a response to end the chain early.

app.use(requestId());          // 1. attach X-Request-Id
app.use(helmet());             // 2. security headers
app.use(cors(corsOptions));    // 3. CORS preflight
app.use(express.json({ limit: '1mb' })); // 4. parse JSON bodies
app.use('/api', apiRouter);    // 5. business routes
app.use(notFound);             // 6. 404
app.use(errorHandler);         // 7. centralized errors (4 args)

Application-level middleware runs for every request. Router-level middleware scopes to a mount path. Route-level middleware attaches to a single handler array: app.post('/pay', verifySignature, rateLimit, handlePayment).

Common middleware categories

  • Securityhelmet sets HTTP headers; never roll your own HSTS or CSP without reading docs.
  • Parsingexpress.json(), express.urlencoded({ extended: true }), express.raw() for webhook signature verification on raw bytes.
  • Loggingmorgan for dev access logs; pino-http for structured JSON in production.
  • Auth — custom JWT middleware or passport strategies; fail closed (401/403) before handlers.
  • Rate limitingexpress-rate-limit or limits at the reverse proxy.

Order bugs are the #1 Express production issue: parsing JSON before verifying a webhook HMAC breaks signature checks because the raw body was consumed. For Stripe-style webhooks, use express.raw({ type: 'application/json' }) on that route only, or verify callback on express.json to capture the buffer.

Request, response, and error handling

req exposes body (after parser middleware), query, params, headers, and ip. res methods chain: res.status(400).json({ error: 'invalid_sku' }). Always set Content-Type explicitly when sending non-JSON bodies.

Async handlers must forward rejections to Express. In Express 4, unhandled promise rejections crash the process or hang the request. Wrap async logic:

const asyncHandler = fn => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/orders/:id', asyncHandler(async (req, res) => {
  const order = await orders.findById(req.params.id);
  if (!order) return res.status(404).json({ error: 'not_found' });
  res.json(order);
}));

Error-handling middleware has four parameters (err, req, res, next) and must be registered last:

function errorHandler(err, req, res, _next) {
  const status = err.status ?? err.statusCode ?? 500;
  const code = err.code ?? 'internal_error';
  req.log?.error({ err, status }, 'request failed');
  res.status(status).json({
    error: code,
    message: status < 500 ? err.message : 'Internal server error',
    requestId: req.id,
  });
}

Map domain errors to HTTP status in service layers (NotFoundError → 404, ValidationError → 400). Never leak stack traces to clients on 500s; log them server-side. Pair with web security hygiene for cookie sessions and SSRF-safe outbound fetches.

Project structure and TypeScript

A maintainable Express codebase separates concerns even though the framework does not enforce it:

  • src/app.ts — create Express app, register global middleware, export app (no listen).
  • src/server.ts — import app, call listen, wire graceful shutdown.
  • src/routes/ — Router modules per domain.
  • src/services/ — business logic, database calls, external APIs.
  • src/middleware/ — auth, validation, error wrappers.

TypeScript improves safety: type req.user via declaration merging on Express.Request, validate bodies with Zod and infer types:

const CreateOrderSchema = z.object({
  customerId: z.string().uuid(),
  lines: z.array(z.object({
    skuId: z.string(),
    quantity: z.number().int().min(1),
  })),
});

function validateBody(schema) {
  return (req, _res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) return next(new ValidationError(result.error));
    req.body = result.data;
    next();
  };
}

Compile with tsc or run directly via tsx in development. Enable strict in tsconfig.json. For teams wanting more structure without Nest’s decorators, consider layering patterns from our TypeScript fundamentals guide and extracting repositories behind service interfaces for testability.

Testing with supertest

Export the Express app without listening; use supertest to issue HTTP requests in tests without binding a port:

import request from 'supertest';
import { app } from '../app.js';

describe('POST /api/v1/orders', () => {
  it('returns 201 for valid payload', async () => {
    const res = await request(app)
      .post('/api/v1/orders')
      .set('Authorization', 'Bearer test-token')
      .send({ customerId: '...', lines: [{ skuId: 'SKU-1', quantity: 2 }] })
      .expect(201);
    expect(res.body.id).toBeDefined();
  });

  it('returns 400 for invalid body', async () => {
    await request(app)
      .post('/api/v1/orders')
      .send({ customerId: 'not-a-uuid' })
      .expect(400);
  });
});

Mock external dependencies at the service boundary (dependency injection or module mocking). Integration tests can spin up a test database with Testcontainers; unit tests should not hit real networks. Exporting app separately from server.ts is the single change that makes CI testing trivial — a pattern Nest enforces by default but Express leaves to you.

Worked example: Harbor Payments webhook service

Harbor Payments receives Stripe-style payment events. The Express service is small but production-hardened:

  • Route: POST /webhooks/stripe mounted with express.raw({ type: 'application/json' }) only on this path.
  • Middleware: verifyStripeSignature compares HMAC using the raw body buffer and Stripe-Signature header; rejects with 400 on mismatch.
  • Handler: parse JSON manually after verification; switch on event.type (payment_intent.succeeded, charge.refunded).
  • Idempotency: store event.id in Redis; skip duplicates — see idempotency patterns.
  • Side effects: enqueue order-fulfillment jobs to BullMQ; respond 200 within two seconds (provider retry window).

Other routes (GET /health, internal admin) use standard express.json(). The app runs behind nginx TLS termination, two replicas on Kubernetes, readiness probe on /health, structured Pino logs with req.id, and SIGTERM handler that stops accepting new connections while in-flight webhooks finish. No ORM in the webhook path — only Redis and a queue — keeping the hot path fast and easy to reason about.

Framework decision table

Choose Express when… Prefer Fastify when… Prefer NestJS when…
Team knows Express; largest middleware ecosystem needed Raw throughput and JSON schema validation matter Large TypeScript team wants DI and module conventions
Webhook receiver or BFF with <30 routes You want built-in logging and sensible defaults OpenAPI, guards, and testing utilities out of the box
Incremental migration from legacy Express code Plugin architecture and encapsulation without decorators Enterprise audit requirements for layered architecture
Maximum Stack Overflow / tutorial coverage Benchmarks show JSON serialization bottleneck Sharing decorators and modules across microservices
Prototype speed; accept manual structure discipline Starting greenfield with performance budget Nest already chosen for sibling services

Common pitfalls

  • Wrong middleware order — auth after body parser on signed webhooks; CORS after error handler.
  • Unhandled async rejections — missing catch(next) or async wrapper in Express 4.
  • God app.js — 2,000 lines with SQL inline; split routers early.
  • No centralized error handler — inconsistent JSON errors and leaked stacks.
  • Trusting req.body without validation — mass assignment and type confusion.
  • Blocking the event loop — sync bcrypt rounds or huge JSON.parse on the main thread.
  • Listening in the same file as app — blocks supertest and clean shutdown tests.
  • Default express.json() on file uploads — use multer or streaming parsers instead.

Production checklist

  • Export app separately from server.ts; enable graceful shutdown on SIGTERM.
  • helmet, restrictive CORS, and rate limits on public routes.
  • Structured logging (Pino) with request IDs propagated from ingress headers.
  • Centralized error handler; safe client messages; full errors logged server-side only.
  • Input validation on every mutating route (Zod, Joi, or express-validator).
  • Health (/health) and readiness endpoints for orchestrator probes.
  • Body size limits on parsers; raw-body route for signed webhooks.
  • Environment config validated at boot; secrets never committed.
  • CI: supertest integration tests for critical paths; npm audit on lockfile.
  • Run behind nginx or a load balancer; terminate TLS at the edge.

Key takeaways

  • Express is routing plus middleware composition — minimal by design, disciplined by convention.
  • Middleware order determines security and correctness; draw the chain on paper for webhook routes.
  • Router modules keep APIs maintainable; pair with service layers for testable business logic.
  • Async errors must reach error-handling middleware; wrap handlers or use Express 5.
  • When structure outgrows manual discipline, graduate to NestJS or enforce lint rules — do not wait for a rewrite crisis.

Related reading