Axum Trailing Slash 404 Issue: Cause, SEO Impact, and the NormalizePathLayer Solution

Recently, after adopting Leptos for my website, I encountered a strange issue during an SEO audit: visiting /2025/post​ returned a 200 OK status, but visiting the same URL with a trailing slash, /2025/post/​, resulted in a 404 error, even though the page content rendered correctly in the browser. This often happens when using Axum to define routes like /post/:slug​. By default, Axum treats a URL with a trailing slash as a different route, and if no corresponding route is defined, it returns a 404. While this doesn't affect the user's ability to view the page, search engines interpret this as a "soft 404," which is very detrimental to SEO. The solution is to use the NormalizePathLayer​ middleware provided by Axum to automatically remove trailing slashes from URLs. This ensures that URLs with or without a trailing slash match the defined route, maintaining a consistent status code and improving the site's SEO performance.

Why Does Axum Return a 404 for URLs with a Trailing Slash?

By default, Axum treats URLs with and without a trailing slash as two distinct routes. If you define a route for /post/:slug​ but not for /post/:slug/​, a request to the latter will fail to match any registered route, triggering Axum's fallback logic (which usually returns a 404 error). Prior to Axum 0.6, the framework handled these cases automatically, but starting from version 0.6, automatic correction of trailing slashes was removed by default to avoid ambiguity.

This strict route matching is an intentional design choice to prevent implicit or unexpected routes. Therefore, /foo​ and /foo/​ are entirely different routes, and if you only define /foo​, a request to /foo/​ will result in a 404 error.

The Confusing SSR Phenomenon: Page Renders, but Status is Still 404

If you are using an SSR framework like Leptos, you might notice that even though your Axum logs show a 404 response for the URL, the page content still renders successfully in the browser. This happens because:

This phenomenon is known as a "soft 404" —the page looks normal to the user, but search engines receive a 404 error status.

The SEO Impact of Trailing Slash 404s

A soft 404 status is harmful to your website's SEO. Search engines rely on correct HTTP status codes for indexing. The specific problems include:

In short, allowing /post/your-article/​ to return a 404, even if the page is visible, is bad for SEO. We want both /post/your-article​ and /post/your-article/​ to return a proper 200 OK status.

The Solution: Unify Paths with NormalizePathLayer

Axum provides a solution to this problem with the NormalizePathLayer​ middleware from tower_http​. This middleware automatically removes the trailing slash from a URL before the request reaches your route definitions. This normalizes a request for /post/your-article/​ to /post/your-article​, allowing it to match the predefined route.

Note: To ensure this middleware works correctly, it must be applied to wrap the entire Router, not on an individual route using Router::layer​. Otherwise, the path will not be adjusted in time for routing.

NormalizePathLayer Example Code

use axum::{
    routing::get,
    Router,
};
use tower_http::normalize_path::NormalizePathLayer;
use tokio::net::TcpListener;

// Handler for a specific blog post
async fn post_handler(axum::extract::Path(slug): axum::extract::Path<String>) -> String {
    format!("Blog post: {}", slug)
}

// Handler for the home page
async fn home_handler() -> &'static str {
    "Welcome to the blog"
}

#[tokio::main]
async fn main() {
    // Define the application routes
    let router = Router::new()
        .route("/posts/:slug", get(post_handler))
        .route("/", get(home_handler));
    
    // Wrap the router with the NormalizePathLayer to trim trailing slashes.
    // This is the key to solving the trailing slash issue.
    let app = NormalizePathLayer::trim_trailing_slash().layer(router);

    // Standard Axum server setup to run the application.
    let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

In this setup, NormalizePathLayer​ automatically removes the trailing slash before routing occurs. This guarantees that both /posts/rust-is-awesome​ and /posts/rust-is-awesome/​ will correctly route to post_handler​ and return a 200 OK status.

Ensuring Correct Placement of NormalizePathLayer

It is crucial to place the middleware correctly. If you mistakenly write it like this:

// Incorrect placement - this will NOT work as intended
let app = Router::new()
    .route("/posts/:slug", get(post_handler))
    .layer(NormalizePathLayer::trim_trailing_slash());

This approach will not work because the middleware is executed after the routing has already happened. The correct method is to wrap the entire router with the middleware, as shown in the correct example above.

你可能也感兴趣