How to use tower middleware with Axum

Stack Tower middleware in Axum using Router::layer or ServiceBuilder to add reusable request handling logic.

When handlers get too heavy

You are building an API. Every endpoint needs request logging. Every endpoint needs CORS headers. Every endpoint needs rate limiting or authentication. Copying that logic into each handler turns your code into a maintenance nightmare. You want a single place to attach cross-cutting concerns, and you want it to work without touching your route handlers.

The checkpoint model

Middleware is a series of transparent filters that wrap your application. Think of it like a series of security checkpoints at an airport. Your request is the passenger. It passes through baggage check, then ticket verification, then metal detection. Each checkpoint can inspect the passenger, modify their documents, or turn them away. When the passenger finally reaches the gate, your route handler, they have already been processed by every checkpoint. On the way back out, the response passes through the same checkpoints in reverse order, allowing each layer to modify the headers or body before it leaves the building.

Tower provides the checkpoint infrastructure. Axum provides the gate. You just supply the security guards. The entire system relies on two traits: Service and Layer. A Service is anything that takes a request and returns a response. A Layer is a factory that takes a Service and wraps it in a new Service. When you attach middleware, you are asking a Layer to wrap your router in a new Service. The compiler verifies that the output type of one layer matches the input type of the next. Type safety guarantees your middleware chain will not panic at runtime.

A single layer in action

Start with one layer to see how Axum accepts it. The Router struct implements Service, which means it can be wrapped directly.

use axum::{routing::get, Router};
use tower_http::trace::TraceLayer;

/// Serves a basic HTTP server with request tracing enabled.
#[tokio::main]
async fn main() {
    // Wrap the router with a tracing layer to log incoming requests.
    let app = Router::new()
        .route("/", get(|| async { "Hello" }))
        .layer(TraceLayer::new_for_http());

    // Bind to localhost and start listening for connections.
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The Router::layer method takes any type that implements tower::Layer. TraceLayer implements that trait. When you call .layer(), Axum hands its internal service to the layer. The layer returns a new service that delegates to the original router but adds logging before and after each request. You do not need to change your route handlers. The layer sits outside them.

Keep middleware at the router level unless you have a specific reason to isolate it. Wrapping the entire router is the standard pattern.

How the wrapping actually happens

Under the hood, Tower uses a functional composition model. Each call to .layer() creates a new Service that holds a reference to the previous one. The request flows inward through the layers, reaches your handler, and the response flows outward.

When a request arrives, the outermost layer receives it first. It can read headers, mutate the request body, or reject the request early. If it allows the request through, it calls .call() on the inner service. This repeats until the request reaches your Axum handler. Your handler returns a response. The response travels back outward through the same layers in reverse order. Each layer can modify the response headers, compress the body, or measure latency.

The compiler enforces this chain at compile time. If a layer changes the request type from Request<Body> to Request<ModifiedBody>, the next layer must accept Request<ModifiedBody>. This is why middleware order is strict. The compiler will reject mismatched types with E0277 (trait bound not satisfied) or E0308 (mismatched types) if you chain layers that expect different request or response shapes.

Treat the middleware chain as a type-safe pipeline. If the types do not line up, the compiler is protecting you from a runtime panic.

Stacking layers in production

Real applications need more than one layer. CORS, compression, tracing, and rate limiting all stack together. Chaining .layer() calls works, but the syntax gets noisy. The ecosystem standard is tower::ServiceBuilder.

use axum::{routing::get, Router};
use tower::{ServiceBuilder, ServiceExt};
use tower_http::{
    cors::CorsLayer,
    trace::TraceLayer,
    compression::CompressionLayer,
};

/// Configures and runs a production-ready HTTP server.
#[tokio::main]
async fn main() {
    // Build a reusable layer stack that applies to the entire router.
    let middleware_stack = ServiceBuilder::new()
        .layer(CompressionLayer::new())
        .layer(CorsLayer::permissive())
        .layer(TraceLayer::new_for_http());

    // Apply the complete stack to the router in a single call.
    let app = Router::new()
        .route("/", get(|| async { "Hello" }))
        .layer(middleware_stack);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

ServiceBuilder collects layers and returns a single Layer that applies them all. The convention in the Rust ecosystem is to write ServiceBuilder::new() and chain .layer() calls in the order you want them applied. The last layer you add becomes the outermost wrapper. This means the last layer in the chain runs first on the request, and first on the response.

A quick convention note: always use tower_http for HTTP-specific middleware like CORS, compression, and tracing. The base tower crate only provides generic utilities like timeouts, retries, and load balancing. Mixing them is fine, but keep HTTP concerns in tower_http to match community expectations.

Write your ServiceBuilder chain bottom-up. The last .layer() call is the first checkpoint your request hits.

Where things break

Middleware chains fail in predictable ways. The most common issue is order. If you put CORS after your authentication layer, the preflight OPTIONS request will never reach the CORS handler. The browser will block the request before your app even sees it. Put CORS at the outermost position.

Type mismatches are the second trap. Some middleware changes the request body type. tower_http::compression::CompressionLayer expects a specific body type. If you wrap it around a layer that already modified the body, the compiler rejects it with E0277. The fix is to place body-modifying layers at the innermost position, closest to your handlers.

State sharing causes the third common error. Middleware cannot use Axum's State extractor directly. Middleware operates at the Service level, which predates Axum's routing. If you need shared configuration in middleware, pass it through the layer constructor, not through route extractors. Trying to force State into a Layer will trigger E0599 (no function named call found) because the trait bounds will not align.

Keep your layers thin and focused. If a layer needs to mutate state, wrap it in a tokio::sync::Mutex or use Arc to share read-only configuration. Do not fight the service boundary.

Picking the right tool

Use Router::layer when you have a single piece of middleware and want to attach it directly to the router. Use ServiceBuilder when you need to stack multiple layers and want a clean, reusable configuration block. Use Router::route_layer when you want middleware to apply only to specific routes instead of the entire application. Use a custom tower::Service when you need fine-grained control over the request/response lifecycle and existing layers do not fit your use case. Reach for tower_http when you need standard HTTP concerns like CORS, compression, or tracing. Reach for base tower when you need generic utilities like timeouts, retries, or load balancing.

Match the tool to the scope. Global concerns go on the router. Route-specific concerns go on the route. Custom logic gets its own service.

Where to go next