Error pages (404, 500)
Default Tailwind error pages, an on_server_error hook, panic safety, and dev-mode error detail.
Drop a 404.html or 500.html in your templates dir and the framework picks it up - two builder methods, both opt-in, both pointing at templates the existing engine renders. Out of the box, umbral also ships its own Tailwind-styled default pages that fire automatically when you haven't provided your own.
Quickstart
use umbral::prelude::*; let app = App::builder() .templates_dir("templates") .not_found_template("404.html") .server_error_template("500.html") .routes(Routes::new().get("/", home)) .build()?;Any path that doesn't match a route renders 404.html; any handler that panics renders 500.html. Both pages return the correct HTTP status (404 and 500 respectively) with Content-Type: text/html; charset=utf-8.
Default error pages
If you don't call .not_found_template(...) or .server_error_template(...), umbral renders its own built-in pages. They use inline Tailwind utility classes and degrade gracefully when Tailwind isn't loaded - the HTML structure is functional without any CSS.
The default pages activate automatically. To opt out and revert to axum's bare behaviour (plain-text "Not Found" and empty 500 bodies):
App::builder() .disable_default_error_pages() .build()?The inline classes work with any stylesheet that includes the Tailwind vocabulary - the Tailwind CDN or any pre-compiled CSS. They do not require a Tailwind build pipeline. A full Tailwind build step is deferred; these pages fall back to functional unstyled HTML when Tailwind isn't loaded.
Catching internal errors
on_server_error hook
Register a closure that fires on every 500 response - both handler panics and any other 500 path - before the template renders. Use it to ship errors to Sentry, Datadog, a log file, or any external sink:
App::builder() .on_server_error(|err, path| { tracing::error!(err, path, "internal server error"); // sentry::capture_message(err, sentry::Level::Error); }) .build()?The hook receives two &str arguments:
| Argument | Description |
|---|---|
err | The panic payload as a string, or the Display form of the error. |
path | The request URI path. Empty string for panic-path errors where the path isn't available. |
The hook is synchronous and runs on the error path before the response is assembled. It cannot change the response - if you need to alter the response shape, use AppError/IntoResponse for the expected-error path instead.
Panic safety
umbral wraps the entire router in tower_http::catch_panic::CatchPanicLayer. A handler that panics produces a graceful 500 response instead of aborting the connection. The panic message is logged via tracing::error! and (in dev mode) surfaced in the page body.
The panic layer is installed whenever:
- A user-supplied
server_error_templateis set, OR - Default error pages are enabled (the default), OR
- An
on_server_errorhook is registered.
In other words: if you use umbral's defaults, you get panic safety for free.
Writing the templates
404.html gets { path } in scope - the request path that missed - so you can show the user exactly where they went wrong:
{% extends "base.html" %}{% block content %} <h1>Page not found</h1> <p>The page <code>{{ path }}</code> doesn't exist.</p> <a href="/">Go home</a>{% endblock %}500.html receives the context variables below. In prod, the dev-only block is simply empty - the template section never renders.
{% extends "base.html" %}{% block content %} <h1>Something went wrong</h1> <p>We've been notified and are looking into it.</p> {% if dev_mode %} <details> <summary>Developer detail</summary> <p>{{ error_display }}</p> <ol> {% for cause in error_chain %} <li>{{ cause }}</li> {% endfor %} </ol> </details> {% endif %}{% endblock %}Dev mode error detail
When settings.environment is Dev (the default), the 500 template receives extra context so you can inspect what went wrong without leaving the browser:
| Variable | Type | Description |
|---|---|---|
dev_mode | bool | true in dev, false in prod. |
error_display | string | The panic message or error Display form. |
error_chain | list[string] | The full std::error::Error::source() chain. |
request_path | string | The path of the failing request. |
In Prod (or Test) mode all four variables are empty / false - the template's {% if dev_mode %} block collapses to nothing, so no internal detail leaks to end users.
Set the environment in umbral.toml or via the environment variable:
# umbral.tomlenvironment = "Prod"UMBRAL_ENVIRONMENT=Prod cargo runWhat gets caught
| Helper | Triggers on |
|---|---|
not_found_template | Any request whose URL didn't match a route. |
server_error_template | Any handler that panics. |
on_server_error hook | Both panics and any 500 constructed by the handler. |
Handlers that return a Result::Err are converted to a response by their IntoResponse impl. For typical error handling, define an AppError type that calls the hook and renders the template:
struct AppError(anyhow::Error); impl IntoResponse for AppError { fn into_response(self) -> Response { // Optionally fire the hook manually for Err paths: umbral_core::errors::fire_server_error_hook( &Some(my_hook.clone()), &self.0.to_string(), "/", ); tracing::error!("handler error: {:?}", self.0); (StatusCode::INTERNAL_SERVER_ERROR, Html("<h1>Something went wrong</h1>")).into_response() }}Use server_error_template for the unexpected path (panic = bug), and AppError/IntoResponse for expected errors that flow through ?.
Why no context on 500 in prod? Exposing panic messages to the browser leaks internals and occasionally credentials (e.g. a panic in a SQL query string). The umbral default keeps the message in logs only and exposes it only in dev mode. If you want to display a request ID or correlation token in prod, generate it in middleware and put it in a response header - then let your 500.html reference it.
Composing with the trailing-slash redirect
If you also set slash_redirect, the redirect runs before the 404 template. Order:
- Request
/articles(no trailing slash) misses. - Slash redirect probes
/articles/(with slash). Matches → 308. - Browser follows; second request succeeds.
404.htmlnever renders.
If neither form matches, the 404 template fires. One consistent 404 page across both raw misses and redirect dead-ends.
Custom pages for other status codes (403, 429, 410, …)
.error_template(status, "name.html") registers a styled page for any status code a handler returns - the same treatment 404/500 get. When a handler returns Err((status, message)) (a bare, non-HTML error response with that status), the template renders in its place, preserving the status code:
use umbral::web::StatusCode; App::builder() .error_template(StatusCode::TOO_MANY_REQUESTS, "429.html") .error_template(StatusCode::FORBIDDEN, "403.html")The template receives { status, status_text, message, request_path, dev_mode } - e.g. {{ status }} → 429, {{ status_text }} → Too Many Requests, {{ message }} → the handler's body.
API / AJAX clients are respected: a request with Accept: application/json keeps the raw status + message (so a fetch() can read it), while browser navigations (Accept: text/html) get the HTML page. Already-HTML responses and unregistered statuses pass through untouched. 404 and 500 keep their dedicated methods above.
Falling back to the raw axum API
The builder helpers cover the easy case. If you need more control - e.g. different 404 pages for /api/... vs everything else, or matching on the Accept header - use Router::fallback directly:
async fn custom_404(req: Request<Body>) -> Response { if req.uri().path().starts_with("/api") { (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response() } else { (StatusCode::NOT_FOUND, Html("<h1>404</h1>")).into_response() }} let router = Router::new() .route("/", get(home)) .fallback(custom_404);When you call .fallback() yourself, the not_found_template helper still runs first - your fallback only fires if neither it nor the slash redirect (if enabled) handles the request.
See also
- Trailing-slash redirects - composition order.
- Rendering HTML - how the engine resolves template paths.
- Auth gating - protecting routes with
LoginRequiredLayer.