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
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:
| Policy | Behaviour |
|---|---|
Off (default) | No redirects. /foo and /foo/ are distinct, axum-style. |
Append | Add the slash. /foo (404) → 308 → /foo/. |
Strip | REST-API convention. /foo/ (404) → 308 → /foo. |
Pick one per app. Mixing isn't supported - the policy is global to the App builder.
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:
- Snapshots the merged router before installing the fallback, so the probe can't recursively re-hit itself.
- On a 404, computes the alternate path per policy (add or strip the trailing slash).
- Probes the snapshot with a synthetic
GETto the alternate. If the probe returns anything but 404 (200, 405, 3xx all count), the alternate exists. - If the alternate exists, returns a 308 redirect to it.
- 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
Appendfor an HTML-shaped app. Both/articlesand/articles/work, but/articles/is canonical. Search engines see one URL for indexing.Stripif you're building a JSON REST API where slashless URLs are the convention (/api/users, not/api/users/).Offif you genuinely want/fooand/foo/to be different resources, or if you want strict 404s for typo'd paths.
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
- Error pages - 404 / 500 handling.
- The Plugin trait - middleware order when plugins ship their own layers.