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 wraps the whole router in a probe layer when the policy isn't Off. The layer:

  1. Snapshots the merged router before the layer is applied, so the probe can't recursively re-enter itself.
  2. Runs the request normally. If the response — from any origin: a matched handler, a wildcard capture like /api/{table}, a nested service, or the 404 fallback — is a 404, it computes the alternate path per policy (add or strip the trailing slash).
  3. Probes the snapshot with a synthetic header-less GET to the alternate. Anything but 404 (200, 405, 401, 3xx all count) means the alternate exists.
  4. If the alternate exists, returns a 308 redirect to it.
  5. Otherwise returns the original 404 untouched — an API's JSON 404 body passes through as-is.

It's a layer, not a fallback, on purpose: a 404 produced by a matched route never reaches a fallback, and wildcard routes make that common — /api/docs/ matches REST's /api/{table}/ route and 404s from inside the handler, so under the old fallback design that URL simply never redirected even with the policy on. The layer sees every 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 the layer applied).

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