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
- Security —
helmetsets HTTP headers; never roll your own HSTS or CSP without reading docs. - Parsing —
express.json(),express.urlencoded({ extended: true }),express.raw()for webhook signature verification on raw bytes. - Logging —
morganfor dev access logs;pino-httpfor structured JSON in production. - Auth — custom JWT middleware or
passportstrategies; fail closed (401/403) before handlers. - Rate limiting —
express-rate-limitor 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, exportapp(nolisten).src/server.ts— importapp, calllisten, 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/stripemounted withexpress.raw({ type: 'application/json' })only on this path. - Middleware:
verifyStripeSignaturecompares HMAC using the raw body buffer andStripe-Signatureheader; rejects with 400 on mismatch. - Handler: parse JSON manually after verification; switch on
event.type(payment_intent.succeeded,charge.refunded). - Idempotency: store
event.idin 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.bodywithout validation — mass assignment and type confusion. - Blocking the event loop — sync bcrypt rounds or huge
JSON.parseon the main thread. - Listening in the same file as
app— blocks supertest and clean shutdown tests. - Default
express.json()on file uploads — usemulteror streaming parsers instead.
Production checklist
- Export
appseparately fromserver.ts; enable graceful shutdown onSIGTERM. 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 auditon 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
- Node.js fundamentals explained — event loop, modules, and async I/O under Express
- NestJS fundamentals explained — opinionated structure built on Express or Fastify
- REST API design explained — resources, status codes, and error shapes
- TypeScript fundamentals explained — typing Express requests and validation pipelines