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 wraps the whole router in a probe layer when the
policy isn't Off. The layer:
- Snapshots the merged router before the layer is applied, so the probe can't recursively re-enter itself.
- 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). - Probes the snapshot with a synthetic header-less
GETto the alternate. Anything but 404 (200, 405, 401, 3xx all count) means the alternate exists. - If the alternate exists, returns a 308 redirect to it.
- 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
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.