This version is in beta. Some features may change before release.

Trailing-slash redirects

Opt-in 308 redirects between `/foo` and `/foo/` so URL typos still reach the right handler.

axum treats /articles and /articles/ as distinct paths. A route registered as one returns 404 for the other. Most apps want both forms to reach the same handler - users shouldn't be able to break sharing a URL by typing or omitting a trailing slash.

umbral solves this with SlashRedirect, opt-in via App::builder().slash_redirect(...). On a 404 for /foo, the framework checks whether /foo/ would have matched and redirects to it if so (and vice versa, depending on the policy).

Quickstart

Code
rust
use umbral::prelude::*;
use umbral::web::SlashRedirect;
 
let app = App::builder()
.slash_redirect(SlashRedirect::Append) // add the trailing slash on a 404
.routes(
Routes::new()
.get("/articles/", list_articles)
.get("/articles/{id}", article_detail),
)
.build()?;

With Append set, a request to /articles (no slash) that would have 404'd gets re-checked: if /articles/ exists, the response becomes a 308 redirect there. The browser follows; the second request hits list_articles. Routes that match on the first try pay zero overhead.

Policies

SlashRedirect has three variants:

PolicyBehaviour
Off (default)No redirects. /foo and /foo/ are distinct, axum-style.
AppendAdd the slash. /foo (404) → 308 → /foo/.
StripREST-API convention. /foo/ (404) → 308 → /foo.

Pick one per app. Mixing isn't supported - the policy is global to the App builder.

Info

Why 308 instead of 301? 308 preserves the HTTP method and body; 301 historically converted POST → GET, which silently dropped form data. umbral picked 308 so a POST /api/users redirected to POST /api/users/ keeps the body intact.

How it works

App::build installs a fallback handler when the policy isn't Off. The handler:

  1. Snapshots the merged router before installing the fallback, so the probe can't recursively re-hit itself.
  2. On a 404, computes the alternate path per policy (add or strip the trailing slash).
  3. Probes the snapshot with a synthetic GET to the alternate. If the probe returns anything but 404 (200, 405, 3xx all count), the alternate exists.
  4. If the alternate exists, returns a 308 redirect to it.
  5. Otherwise returns the original 404.

Query strings are preserved: /articles?page=2 redirects to /articles/?page=2. No infinite loops - if both /foo and /foo/ are 404, the second request just returns 404 cleanly (the snapshot doesn't have a fallback installed).

When to pick which

  • Append for an HTML-shaped app. Both /articles and /articles/ work, but /articles/ is canonical. Search engines see one URL for indexing.
  • Strip if you're building a JSON REST API where slashless URLs are the convention (/api/users, not /api/users/).
  • Off if you genuinely want /foo and /foo/ to be different resources, or if you want strict 404s for typo'd paths.
Warning

The probe adds one extra service call per 404. That's negligible in practice - 404s should be rare - but if you have a very hot 404 path (e.g. a misconfigured monitor hammering a wrong URL), each one pays the probe cost. The fallback only fires when the original request didn't match any route, so steady-state traffic is unaffected.

Interaction with custom fallback handlers

If you also call Router::fallback(your_handler), the framework's slash-redirect fallback takes precedence - your handler only runs when the slash redirect itself returns 404 (i.e. neither form matches). Custom 404 pages still work; see Error pages for the full pattern.

See also