How to compose and write middleware in serve.build, with examples of the built-in middleware.
Middleware
Middleware wraps handlers. Register it with .middleware() on RouterBuilder —
first-registered is the outermost wrapper, so it runs first on the way in and
last on the way out.
A typical middleware stack looks like this:
RouterBuilder.create()
.middleware(RequestLoggingMiddleware.defaults()) // outermost
.middleware(CorsMiddleware.allowAll())
.middleware(SecurityHeadersMiddleware.defaults())
.middleware(CompressionMiddleware.defaults())
.middleware(new JsonMiddleware()) // innermost
.route("/", myRouter)
.build();
Writing your own
Middleware is a @FunctionalInterface: Handler apply(Handler next). Wrap the
next handler however you like:
Middleware timer = next -> exchange -> {
long start = System.nanoTime();
next.handle(exchange);
long elapsed = (System.nanoTime() - start) / 1_000_000;
System.out.println(exchange.request().path() + " took " + elapsed + "ms");
};
Built-in middleware
Logging
RequestLoggingMiddleware logs method, path, status, and timing for every
request. Call .defaults() for zero-config access logging, or build a custom
instance to set a slow-request threshold and exclude health-check paths from the
log.
.middleware(RequestLoggingMiddleware.defaults())
CORS
CorsMiddleware adds Access-Control-* headers and handles OPTIONS preflight.
allowAll() is fine for development. In production, restrict to your actual
frontend origin:
.middleware(CorsMiddleware.builder()
.allowOrigins("https://app.example.com")
.allowMethods("GET", "POST", "PUT", "DELETE")
.allowHeaders("Authorization", "Content-Type")
.build())
Security headers
SecurityHeadersMiddleware.defaults() sets a standard hardening suite on every
response: X-Frame-Options: DENY, X-Content-Type-Options: nosniff,
Strict-Transport-Security, Referrer-Policy, Cross-Origin-Opener-Policy,
and Cross-Origin-Resource-Policy. Works out of the box for most applications.
.middleware(SecurityHeadersMiddleware.defaults())
Compression
CompressionMiddleware transparently compresses responses with gzip or deflate
based on Accept-Encoding. Buffers the response body before compressing, so
it's incompatible with streaming handlers that use response.bodyAsStream().
.middleware(CompressionMiddleware.defaults())
JSON
JsonMiddleware injects Jackson body reading and writing into the exchange.
Without it, exchange.bodyAs(T) and exchange.response().json(obj) throw
UnsupportedOperationException.
.middleware(new JsonMiddleware())
Rate limiting
RateLimitMiddleware enforces a token-bucket limit per request key. The default
key is the first IP in X-Forwarded-For. Every response gets
X-RateLimit-Limit and X-RateLimit-Remaining headers; rate-limited responses
get a 429 with Retry-After.
.middleware(RateLimitMiddleware.builder()
.limit(100)
.per(Duration.ofMinutes(1))
.build())
Rate limit by user ID or API key instead of IP:
.middleware(RateLimitMiddleware.builder()
.limit(1000)
.per(Duration.ofHours(1))
.keyExtractor(req -> req.header("X-Api-Key").orElse("anonymous"))
.build())
Health endpoints
HealthHandler is mounted as a route rather than wrapped middleware. It exposes
/live (always 200) and /ready (runs named checks, returns 503 if any fail):
.route("/health", HealthHandler.create()
.liveness("/live")
.readiness("/ready",
HealthCheck.of("db", () -> db.isAlive()))
.build())
Static files
StaticFileHandler serves files from the classpath or filesystem with ETag/304
support. Mount it at any path prefix:
// Classpath resources (bundled with the application)
.route("/static", StaticFileHandler.resources(MyApp.class, "/static"))
// Filesystem directory
.route("/static", StaticFileHandler.directory(Path.of("public")))