Routing
A Router matches requests to handlers. You can derive paths from Rust modules or write them directly on handlers. Module routing is the recommended default. Both approaches build the same router, which you pass to start to serve requests.
Module routing
Call module_router! in the root of your route tree, then build. Handlers omit path strings and take their paths from the enclosing modules:
use hearth::router::{Router, module_router};
// src/app.rs: this module maps to /.
pub fn router() -> Router {
module_router!().build()
}
Declare child modules with mod, just as in any Rust application. A #[page] in app::about serves /about, and one in app::settings::profile serves /settings/profile. The function name does not affect the URL. See the module routing guide for setup, parameters, groups, and segment overrides.
Explicit paths
To choose paths directly, put an absolute path in each handler's attribute and register the handlers on Router::builder. Their Rust module locations do not affect the URLs:
use hearth::{
Result,
router::{Router, page, route},
view::{View, view},
};
#[page("/")]
async fn home() -> Result<impl View> {
Ok(view! { <h1>"Home"</h1> })
}
#[route(GET "/api/health")]
async fn health() -> Result<&'static str> {
Ok("ok")
}
pub fn router() -> Router {
Router::builder().page(home).route(health).build()
}
Layouts and layers work the same way: #[layout("/settings")] and #[layer("/api")] declare path prefixes, and .layout(...) and .layer(...) register them. See manual registration for all four handler kinds together.
With the discover feature enabled, Router::builder().discover().build() collects explicit-path handlers automatically. Import RouterBuilderDiscoverExt to use it. This changes registration only; you still write paths in the attributes. See auto-discovery for details.
You can also combine the approaches. module_router!() collects module-derived handlers; chain .page(home) or .route(health) to add an explicit-path handler, or .discover() to collect them all.
Linking to pages and routes
Use href! for URLs targeting Hearth handlers, including navigation links, form actions, and redirects. Pass the handler's Rust name so the URL follows its registered path when you rename a module or change an explicit path. Pass each path parameter as a path_param! value:
use hearth::{router::href, view::view};
Ok(view! {
<a href=(href!(posts))>"All posts"</a>
<a href=(href!(post_id::post, post_id::PostId(1)))>"First post"</a>
<form method="post" action=(href!(publish))>
<button>"Publish"</button>
</form>
})
A view renders the returned Href directly. Call resolve when a redirect helper needs a string:
use hearth::{
Result,
context::Cx,
router::{error::{SeeOther, see_other}, href, route},
};
#[route(POST)]
async fn publish(cx: &Cx) -> Result<SeeOther> {
// Save the post, then redirect to the list.
Ok(see_other(href!(posts).resolve(cx)))
}
Use ordinary URLs for external destinations and standalone fragment links such as href="#details". The href! guide covers queries, fragments, and URL forms. See layouts below for active navigation with Href::is_current and class!.
Path syntax
Explicit route paths use Hearth's Path syntax. A path is made of /-separated segments, and each segment is one of four kinds.
/usersis a static segment./{id}is a dynamic parameter that matches one non-empty segment./{*path}is a catch-all parameter that matches one or more remaining segments./(marketing)is a group. Groups take part in layout and layer matching but are stripped from the served URL.
The root path is /. Every other path starts with / and has no empty segments, except that it may end in a /. Parameter and group names start with an ASCII letter or _ and contain only ASCII letters, digits, and underscores.
A trailing slash is part of the path. A page at /users/ is served at /users/, and a page at /users is served at /users. A request for the other form is redirected to the declared one by default. Use RouterBuilder::trailing_slash to configure this behavior.
Pages
A page is an async function annotated with #[page], returning a rendered view:
// src/app.rs
use hearth::{Result, router::page, view::{View, view}};
#[page]
async fn home() -> Result<impl View> {
Ok(view! { <h1>"Home"</h1> })
}
mod users {
mod id {
use hearth::{Result, router::{module_param, page}, view::{View, view}};
module_param!(id);
#[page]
async fn user_profile() -> Result<impl View> {
Ok(view! { <h1>"User profile"</h1> })
}
}
}
A page serves GET by default. Naming methods, such as #[page(POST)], overrides that, with the same method forms as #[route].
See #[page] for the handler signature, module-derived paths, and using pages as components.
Layouts
A layout wraps pages. It receives the inner page (or nested layout) as a Slot, to embed in its own view. Annotate it with #[layout]:
// src/app.rs
use hearth::{
Result,
context::Cx,
router::{Slot, href, layout},
view::{View, class, view},
};
#[layout]
async fn root_layout(cx: &Cx, slot: Slot<'_>) -> Result<impl View> {
let about_link = href!(about::page);
let current = about_link.is_current(cx);
Ok(view! {
<!DOCTYPE html>
<html>
<body>
<nav>
<a href=(href!(home))>"Home"</a>
<a
href=(about_link)
aria-current=(current.then_some("page"))
class=(class!("nav-link", "active" if current))
>
"About"
</a>
</nav>
(slot)
</body>
</html>
})
}
A layout matches a page by path segments. A layout at / wraps all pages. One at /settings wraps /settings and pages below it, such as /settings/profile. Matching layouts nest with the least specific path outside the most specific one. See #[layout] for details.
The navigation uses Href::is_current to identify the active link and class! to combine its base and conditional classes.
Layers
A layer wraps request handling under its path prefix. It receives the request context, the request body, and Next, which represents the remaining layers and the handler. A layer that registers request-scoped values derives a child context with Cx::with and passes that to next.run:
// src/app.rs
use hearth::{
Result,
context::Cx,
router::{Body, Next, layer, response::Response},
};
#[layer]
async fn timing(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
let start = std::time::Instant::now();
let response = next.run(cx, body).await?;
println!("handled in {:?}", start.elapsed());
Ok(response)
}
Layers follow the same prefix rule as layouts and nest from least specific (outermost) to most specific (innermost). A layer whose path is None wraps every request, including one that matches no route: the 404 or 405 error comes back through it as the Err returned by next.run. See #[layer] for the exact matching and ordering rules.
API routes
An API route is an async function annotated with #[route] and an explicit HTTP method:
// src/app/api/health.rs
use hearth::{Result, router::route};
#[route(GET)]
async fn health() -> Result<&'static str> {
Ok("ok")
}
The method can also be a bracketed list (#[route([GET, POST])]) registering the handler for each listed method, or * (#[route(*)]) registering it for every method. A route declaring a specific method takes precedence over a * route at the same path.
See #[route] for the handler signature and how return values convert into responses.
Request and response bodies
A page or route handler can take the request context as cx: &``Cx and, alongside it, a single request body parameter, such as a Json body. An API route additionally returns a value that becomes the response.
// src/app/api/users.rs
use hearth::{
Result,
context::Cx,
router::{content::Json, route},
};
#[route(POST)]
async fn create_user(cx: &Cx, Json(input): Json<CreateUser>) -> Result<Json<User>> {
// ...
}
Both parameters are optional and may appear in either order. A handler can take only one body parameter because the body can be consumed only once. Pages parse bodies the same way and return a view. See content for request extractors and response types.
Path and query parameters
Read path and query values from Cx. Any helper with access to the context can read them.
Path parameters
path_param! declares a path parameter and generates a Pascal-cased type, so path_param!(post_id: u64) declares PostId for {post_id}. For module routing, use module_param!. It makes the same declaration and also sets the module's URL segment to that parameter.
Both macros accept the same options, and you read their values with path_param::<T>(cx):
- After
path_param!(slug),path_param::<Slug>(cx)returns the percent-decoded segment as&str. - A type after
:is parsed withFromStr. The default return type isResult<&T, &<T as FromStr>::Err>. - An
error = ...option maps parse failures to a router error. Seepath_param!for the supported forms.
// src/app/posts/post_id.rs
use hearth::{
Result,
context::Cx,
router::{module_param, page, path_param},
view::{View, view},
};
module_param!(post_id: u64, error = bad_request);
#[page]
async fn post(cx: &Cx) -> Result<impl View> {
let post_id = path_param::<PostId>(cx)?;
Ok(view! { <h1>"Post " (post_id)</h1> })
}
Parsing occurs once per request and the result is memoized.
Prefix the name with * to capture the remaining path as decoded segments. After path_param!(*doc_path), path_param::<DocPath>(cx) returns CatchAllSegments. After path_param!(*ids: u32), path_param::<Ids>(cx) returns Result<&[u32], _>.
See module_router! for nested modules and catch-all routes, or path_param! for parameters in explicit handler paths.
Query parameters
Apply #[query_params] to a struct with named fields. The macro derives serde::Deserialize, and query_params::<T>(cx) deserializes the request query string into that struct. Use Option<T> for keys that may be absent.
// src/app/posts.rs
use hearth::{
Result,
context::Cx,
router::{page, query_params},
view::{View, view},
};
#[query_params(error = bad_request)]
struct PostsQuery {
page: Option<u32>,
q: Option<String>,
}
#[page]
async fn posts(cx: &Cx) -> Result<impl View> {
let query = query_params::<PostsQuery>(cx)?;
Ok(view! {
<p>"page: " (query.page.unwrap_or(1))</p>
<p>"search: " (query.q.as_deref().unwrap_or(""))</p>
})
}
Query parsing also occurs once per request and returns a reference to the memoized value. A query struct is independent of the matched route, so any handler with cx: &Cx can read it. See #[query_params] for error handling and redirecting after invalid input.
Errors
Handlers return a Result. The router converts an unhandled error into an HTTP response. Router errors select a status code, while other errors produce 500 Internal Server Error.
The error module has a constructor for each response, like not_found() or redirect(uri), and the RouterErrorExt methods that turn an Option or Result into one:
// src/app/dashboard.rs
#[page]
async fn dashboard(cx: &Cx) -> Result<impl View> {
let _user = current_session(cx).await.ok_or_unauthorized()?;
Ok(view! { <h1>"Dashboard"</h1> })
}
See the error module docs for how to raise, convert, and catch these errors.
Status codes and headers
A StatusCode in a view!'s body sets the response status, and a HeaderMap or a single (HeaderName, HeaderValue) pair adds response headers. This pairs with error handling. Wrapping the slot in an error_boundary lets a layout catch a page's NotFoundError and replace it with a branded not-found page:
use hearth::{
Result,
context::Cx,
router::{
Slot, StatusCode,
error::{NotFoundError, RouterErrorExt},
layout, module_param, page,
},
view::{View, error_boundary, view},
};
mod posts {
mod id {
use super::super::*;
module_param!(id);
#[page]
async fn post(cx: &Cx) -> Result<impl View> {
let post = find_post(cx).await.ok_or_not_found()?;
Ok(view! { <h1>(post.title)</h1> })
}
}
}
#[layout]
async fn root_layout(slot: Slot<'_>) -> Result<impl View> {
Ok(view! {
<html>
<body>
error_boundary(
fallback: |error| {
if error.downcast_ref::<NotFoundError>().is_none() {
// Any other error type is rethrown.
return Err(error);
}
Ok(view! {
(StatusCode::NOT_FOUND)
<h1>"Page not found"</h1>
})
},
(slot)
)
</body>
</html>
})
}
See the view! macro docs for the full placement and precedence rules, and the error module docs for catching errors.
Cross-origin requests
The router applies an OriginPolicy to every request before any layer or handler runs: by default, state-changing cross-origin browser requests and cross-origin WebSocket handshakes are rejected with 403 Forbidden. Register a policy with origin_policy to trust cross-origin peers, to exempt individual routes, or to opt out; see OriginPolicy for the exact rules.
Manual registration
For routes with explicit paths, register each handler with .page(), .layout(), .layer(), or .route(), then call build:
use hearth::router::Router;
pub fn router() -> Router {
Router::builder()
.layout(root_layout)
.layout(settings_layout)
.layer(timing)
.page(home)
.page(about)
.page(profile)
.route(health)
.build()
}
Layout and layer matching is based on path prefixes, not registration order; see #[layout] and #[layer] for the ordering rules.
Auto-discovery with discover()
With the discover feature enabled, explicit-path #[page], #[layout], #[layer], and #[route] handlers can be collected at link time. Instead of listing each item by hand, call discover on the builder:
use hearth::router::{Router, RouterBuilderDiscoverExt};
pub fn router() -> Router {
Router::builder().discover().build()
}
This finds annotated items across your crate and dependencies. Discovered layers must have unique paths because link-time collection order is not stable; if you need to stack several layers on one path, register them explicitly with .layer(...).
Discovery also registers items collected by enabled integrations. It does not replace their setup steps. Follow each integration's guide to register any configuration or services it requires.
module_router! registers module-derived handlers only. It returns a RouterBuilder, so call discover() on it, or register the remaining items by hand, exactly as above.
Static files
For application assets such as images and stylesheets, we recommend the asset system. It generates content-hashed URLs for long-lived caching, and Rust asset handles help avoid typos in URL strings. Serving a directory is useful when files need fixed URLs or are created at runtime.
Enable the fs feature to serve files from a directory. Import RouterBuilderDirectoryExt and call public_dir to serve them at the site root:
use hearth::router::{RouterBuilderDirectoryExt, module_router};
let router = module_router!().public_dir("./public").build();
This serves ./public/logo.svg at /logo.svg and ./public/css/site.css at /css/site.css. The directory is relative to the process's current working directory. The root URL / is left available for a home page.
Use serve_dir to choose a different route path. Its final catch-all parameter selects the file within the directory:
use hearth::router::{RouterBuilderDirectoryExt, module_router};
let router = module_router!()
.serve_dir("/downloads/{*file}", "./files")
.build();
This serves ./files/report.pdf at /downloads/report.pdf. The catch-all does not match /downloads or /downloads/ itself.
Serving
Use start to run a finalized router:
#[tokio::main]
async fn main() {
let router = my_app::router();
hearth::start(router).await.unwrap();
}
start reads HOST and PORT, with 127.0.0.1:3000 as the default. Use serve to supply your own Listener. On Unix, a UnixListener can accept requests forwarded by a reverse proxy:
let path = "/run/my-app.sock";
let _ = std::fs::remove_file(path);
let listener = tokio::net::UnixListener::bind(path)?;
hearth::serve(listener, router).await
The socket file of a previous run is not removed automatically, so remove any stale file before binding, as above.
The HTTP server requires the serve feature, which is enabled by default. When another platform receives requests for you, call Router::handle to turn a Request into a Response. This works without serve.
Tower services
With the tower feature enabled, the tower module bridges the tower ecosystem: TowerRoute mounts a tower service (like an axum router) as a route, and TowerLayer runs tower middleware as a layer. See the tower module docs for details.
Example: full manual setup
use hearth::{
Result,
context::Cx,
router::{
Body, Next, Router, Slot, content::Json, href, layer, layout, page, response::Response, route,
},
view::{View, view},
};
#[derive(serde::Deserialize, serde::Serialize)]
struct NewUser {
name: String,
}
#[layout("/")]
async fn root_layout(slot: Slot<'_>) -> Result<impl View> {
Ok(view! {
<!DOCTYPE html>
<html>
<body>
<nav>
<a href=(href!(home))>"Home"</a>
<a href=(href!(users_list))>"Users"</a>
</nav>
(slot)
</body>
</html>
})
}
#[layer("/api")]
async fn api_log(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
let response = next.run(cx, body).await?;
println!("API response: {}", response.status());
Ok(response)
}
#[page("/")]
async fn home() -> Result<impl View> {
Ok(view! { <h1>"Welcome"</h1> })
}
#[page("/users")]
async fn users_list() -> Result<impl View> {
Ok(view! { <h1>"All users"</h1> })
}
#[page("/users/{id}")]
async fn user_profile() -> Result<impl View> {
Ok(view! { <h1>"User profile"</h1> })
}
#[route(GET "/api/health")]
async fn health() -> Result<&'static str> {
Ok("ok")
}
// Reads a JSON request body and echoes it back as a JSON response.
#[route(POST "/api/users")]
async fn create_user(Json(user): Json<NewUser>) -> Result<Json<NewUser>> {
Ok(Json(user))
}
pub fn router() -> Router {
Router::builder()
.layout(root_layout)
.layer(api_log)
.page(home)
.page(users_list)
.page(user_profile)
.route(health)
.route(create_user)
.build()
}
Example: same app with discover()
use hearth::router::{Router, RouterBuilderDiscoverExt};
// The page, layout, layer, and route definitions are identical. Only the
// router function changes.
pub fn router() -> Router {
Router::builder().discover().build()
}
All #[page], #[layout], #[layer], and #[route] items from the example above are picked up automatically.