What is middleware in NestJS and how does it differ from guards?
Middleware in NestJS is a function that intercepts every HTTP request before it reaches a route handler, with direct access to Express's req, res, and next().
Theory
TL;DR
- Middleware runs at the Express layer, before any NestJS processing (pipes, guards, interceptors)
- Guards run after middleware, per-route, with access to
ExecutionContextand route metadata - Think of middleware as airport security (every passenger passes through) and guards as gate agents (check tickets per flight)
- Decision rule: logging, CORS, body parsing -> middleware. JWT validation, role checks -> guards
- Never use guards for CORS. Preflight requests never reach them
Quick example
// logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log(`${req.method} ${req.url} at ${new Date().toISOString()}`);
// Output: "GET /users at 2024-01-15T12:00:00Z"
next(); // without this, the request hangs forever
}
}
// app.module.ts - runs BEFORE any guard
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes('*');
}
}Every request hits this log before NestJS checks authentication. The next() call is what advances the request through the pipeline.
Key difference
Middleware executes at the raw Express layer. It gets req, res, and next() - nothing more. A guard executes later, with a NestJS ExecutionContext that lets it read decorators, reflect metadata, and make route-aware decisions. Middleware doesn't know which controller a request is heading to. A guard does, and that's why auth belongs in guards.
When to use
- App-wide logging, CORS headers, compression -> middleware. It applies uniformly to all matching paths before NestJS does any processing.
- JWT validation, role-based access, ownership checks -> guards. They can read
@Roles()decorator metadata and throw a properForbiddenException. - Request ID injection, tenant ID extraction from subdomain -> middleware. This runs early enough to attach data that later handlers can use.
- CORS specifically -> always middleware or
app.enableCors(). Guards run too late; preflight requests fail. - Business logic -> neither. That belongs in services.
Comparison table
| Aspect | Middleware | Guards |
|---|---|---|
| Execution timing | Earliest - Express layer, before pipes | After middleware, before handler |
| Scope | All paths or specific paths/modules | Per-route, per-controller, or global |
| Access | req, res, next() | ExecutionContext (NestJS-aware) |
| Short-circuit | res.send() or skip next() | Return false or throw exception |
| DI support | Class-based only | Class-based or functional |
| Can read route metadata | No | Yes, via Reflector |
| Best for | Logging, CORS, rate limiting | Auth, roles, ownership |
Execution order
HTTP Request
→ Middleware (in registration order)
→ Guards
→ Interceptors (before handler)
→ Pipes
→ Route Handler
→ Interceptors (after handler)
→ ResponseNestJS compiles middleware into Express's middleware stack via app.use() under the hood. The configure(consumer) method builds path-matched routers that execute sequentially per request. Guards bind later via metadata reflection on controllers and routes, which is also why they can read custom decorators.
Registering middleware
Guards attach with a decorator. Middleware goes through the module's configure() method:
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// Apply to all routes
consumer.apply(LoggerMiddleware).forRoutes('*');
// Apply to a specific controller, excluding public auth routes
consumer
.apply(AuthMiddleware)
.exclude(
{ path: 'auth/login', method: RequestMethod.POST },
{ path: 'auth/register', method: RequestMethod.POST },
)
.forRoutes(UsersController);
// Chain multiple middleware - they run in this exact order
consumer
.apply(CorsMiddleware, HelmetMiddleware, LoggerMiddleware)
.forRoutes('*');
}
}A guard, by contrast, attaches with a single decorator anywhere: @UseGuards(JwtAuthGuard).
Common mistakes
Forgetting next():
// Wrong - request hangs indefinitely
use(req: Request) {
console.log('logged');
// no next() call
}
// Right
use(req: Request, res: Response, next: NextFunction) {
console.log('logged');
next();
}This is behind most "my NestJS server stopped responding" reports on Stack Overflow.
Passing an instance instead of the class:
// Wrong - breaks DI, NestJS cannot inject dependencies
consumer.apply(new LoggerMiddleware()).forRoutes('*');
// Right - NestJS handles instantiation and injection
consumer.apply(LoggerMiddleware).forRoutes('*');Using guards for CORS:
// Wrong - runs too late, OPTIONS preflight request fails
@UseGuards(CorsGuard)
@Get()
findAll() {}
// Right
consumer.apply(cors()).forRoutes('*');
// or in main.ts: app.enableCors()Async middleware without error passing:
// Risky - thrown errors are swallowed in NestJS v9+
async use(req: Request, res: Response, next: NextFunction) {
await someDbCheck(); // if this throws, nothing happens
next();
}
// Safe
async use(req: Request, res: Response, next: NextFunction) {
try {
await someDbCheck();
next();
} catch (err) {
next(err); // pass to Express error handler
}
}Real-world usage
- API gateways:
express-rate-limitas middleware before any route processing - Multi-tenant apps: extract tenant ID from subdomain in middleware, attach to
reqfor controllers and guards to consume - Audit trails: log masked user IDs (
req.headers['x-user-id'].slice(0, 4) + '****') before business logic touches the request - Security headers:
helmet()as middleware before any handler - Same app, guards handle: JWT validation with
@UseGuards(JwtAuthGuard)on protected controllers
One pattern that works well in production: lightweight logging middleware on all routes, RateLimitMiddleware on api/*, then guards for actual auth. Each layer does exactly one job.
Follow-up questions
Q: When in the request lifecycle does middleware run relative to interceptors?
A: Middleware runs first, at the Express layer. After that: guards, interceptors (before handler), pipes, the handler, then interceptors (after handler).
Q: How do you apply middleware to specific HTTP methods only?
A: Use forRoutes({ path: '/users', method: RequestMethod.POST }). You can chain multiple forRoutes calls on one apply for different method/path combinations.
Q: What is the performance difference between class-based and functional middleware?
A: Functional middleware has no DI container overhead and a slightly lower memory footprint. Use it for stateless tasks like simple logging. Use class-based when you need to inject services.
Q: Can middleware read custom decorators or route metadata?
A: No. Middleware has no ExecutionContext and no access to Reflector. If you need to read @Roles() or any custom decorator metadata, that logic belongs in a guard.
Q: In a microservice using gRPC transport, does middleware apply?
A: No. Middleware is HTTP-only and tied to the HTTP adapter (Express or Fastify). For gRPC, use interceptors and handle transport-level metadata via RpcException.
Examples
Basic: logging middleware with timing
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on('finish', () => {
// Logs after response is sent: "GET /api/users 200 45ms"
console.log(`${req.method} ${req.url} ${res.statusCode} ${Date.now() - start}ms`);
});
next();
}
}res.on('finish') captures the status code and duration after the response completes. The middleware doesn't block the handler - it hooks into the response lifecycle and logs after the fact.
Intermediate: request ID middleware
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { v4 as uuid } from 'uuid';
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// Reuse client-sent ID or generate a fresh one
const requestId = (req.headers['x-request-id'] as string) || uuid();
req['requestId'] = requestId; // attach for controllers and services
res.setHeader('x-request-id', requestId); // echo back to client
next();
}
}Any controller, service, or guard running after this middleware can read req['requestId'] for distributed tracing. The same ID appears in the response header, so the client can correlate its own logs with server logs.
Advanced: async versioned middleware interacting with guards
// version.middleware.ts
@Injectable()
export class VersionMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) {
if (req.url.startsWith('/api/v2') && !req.headers['api-key']) {
// Short-circuits before any guard runs
return res.status(401).json({ error: 'API key required for v2 endpoints' });
}
try {
await validateApiKey(req.headers['api-key'] as string); // async DB/cache check
next();
} catch (err) {
next(err); // pass to Express error handler, not swallowed
}
}
}
// app.module.ts
configure(consumer: MiddlewareConsumer) {
consumer
.apply(VersionMiddleware)
.forRoutes({ path: 'api/v2/*', method: RequestMethod.ALL });
}This middleware stops v2 requests with no API key before they reach a guard. The JwtAuthGuard on the controller still runs for valid requests, giving you two independent access control layers without mixing their responsibilities.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.