Browse documentation
Documentation

Module routing

Edit this guide on GitHub

Module routing is the recommended default for Hearth applications. The module_router! macro derives a handler's path from its enclosing Rust module. Use #[page], #[layout], #[layer], and #[route(GET)] without path strings, and organize routes in modules.

A handler whose path string starts with ./ is served below the module path. Absolute path strings are ignored by the module router entirely. Use them when a handler's URL needs to be independent of the module tree, and register that handler separately as described under "Explicit absolute paths".

Setup

Call module_router!() from the root module of the route tree. That module maps to /. The macro returns a RouterBuilder, so add everything else your application needs on that builder before calling .build().

// src/app.rs
use hearth::router::{Router, module_router};

pub fn router() -> Router {
    module_router!().build()
}

module_router! uses link-time discovery and requires the discover feature. The hearth crate enables it by default.

The macro does not scan the filesystem. Rust must compile each route module through a mod declaration:

src/
  app.rs          # contains `mod settings;`
  app/
    settings.rs   # contains `mod profile;`
    settings/
      profile.rs

Every module-derived #[page], #[layout], #[layer], and #[route] under the module containing module_router!() is registered.

Registering everything else

module_router!() registers module-derived handlers. Register other handlers and application services on the returned builder, just as you would with Router::builder().

Call RouterBuilderDiscoverExt::discover to add explicit-path handlers and other items collected through discovery:

use hearth::router::{Router, RouterBuilderDiscoverExt, module_router};

pub fn router() -> Router {
    module_router!().discover().build()
}

Anything that is registered as a value is passed in by hand. The asset bundle is the common case: a view that renders an Asset, such as a Tailwind stylesheet or a self-hosted font, needs the bundle on the router.

use hearth::{
    asset::{AssetBundle, RouterBuilderAssetExt},
    router::{Router, RouterBuilderDiscoverExt, module_router},
};

pub fn router() -> Router {
    module_router!()
        .discover()
        .assets(AssetBundle::load().unwrap())
        .build()
}

Some integrations require registered application values. A missing value may cause a panic when a request first uses it. Follow the integration's setup guide when adding it to the router.

How modules map to routes

Each module below the root contributes one path segment. Static module names are converted to kebab-case.

ModuleRoute path
app/
app::about/about
app::blog_posts/blog-posts
app::settings::profile/settings/profile

The function name does not affect the path. Two module-derived handlers in the same module receive the same path.

Use href! to link to a handler by its Rust name, such as href!(settings::profile::page), so the URL follows its registered path.

Pages, layouts, layers, and API routes

A #[page] serves GET unless the attribute declares other methods, such as #[page(POST)]. A #[layout] wraps pages in its module and descendant modules.

// src/app.rs: both handlers use "/"
#[layout]
async fn root_layout(slot: Slot<'_>) -> Result<impl View> {
    Ok(view! {
        <html><body>(slot)</body></html>
    })
}

#[page]
async fn home() -> Result<impl View> {
    Ok(view! { <h1>"Home"</h1> })
}
// src/app/about.rs: GET /about
#[page]
async fn about() -> Result<impl View> {
    Ok(view! { <h1>"About"</h1> })
}

An API route declares one method, a method list, or every method:

// src/app/api/health.rs: GET /api/health
#[route(GET)]
async fn health() -> Result<&'static str> {
    Ok("ok")
}

A layer uses its module path as a prefix:

// src/app/api.rs: wraps handlers under /api
use hearth::{
    Result,
    context::Cx,
    router::{Body, Next, layer, response::Response},
};

#[layer]
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)
}

Relative paths

A relative path string starting with ./ is joined onto the module path. This places a handler below its module without adding a module for it.

// src/app/settings.rs: GET /settings
#[page]
async fn settings() -> Result<impl View> {
    Ok(view! { <h1>"Settings"</h1> })
}

// src/app/settings.rs: POST /settings/export
#[page(POST "./export")]
async fn export() -> Result<impl View> {
    Ok(view! { <p>"Export started"</p> })
}

This can also be used to add a trailing slash to the end of your module path. A bare ./ serves the module path itself with a trailing slash, and a relative path ending in a slash keeps it.

// src/app/settings.rs: GET /settings/
#[page("./")]
async fn settings() -> Result<impl View> {
    Ok(view! { <h1>"Settings"</h1> })
}

// src/app/settings.rs: GET /settings/export/
#[page("./export/")]
async fn export() -> Result<impl View> {
    Ok(view! { <h1>"Export"</h1> })
}

The same form works for #[layout], #[layer], and #[route].

Dynamic path parameters

path_param! declares a path parameter. For module routing, use module_param!, which makes the same declaration and also uses the parameter as the module's URL segment. Both accept the same options, and you read their values with path_param::<T>(cx).

For example, module_param!(post_id: u64) gives the module a {post_id} segment and creates the PostId type used to read its value:

src/
  app.rs                  # `mod posts;`
  app/
    posts.rs              # `mod post_id;`, route prefix /posts
    posts/
      post_id.rs          # route /posts/{post_id}
// 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> })
}

This page serves /posts/{post_id}. A request for /posts/42 parses 42 with u64::from_str. A failed parse returns 400 Bad Request because the declaration uses error = bad_request.

The parameter name comes from post_id in the declaration, not from the filename. The file could be named id.rs and would still contribute {post_id}.

path_param::<T>(cx) returns a request-scoped value:

Parsing occurs once per request. Later calls return the memoized result.

A module adds one URL segment and can contain one module_param!. To put several parameters in a route, declare them in nested modules:

ModuleRoute path
app::organizations::organization_id/organizations/{organization_id}
app::organizations::organization_id::users::user_id/organizations/{organization_id}/users/{user_id}

Handlers and layouts in descendant modules can read parameters declared by ancestor modules if the Rust types are visible there.

Catch-all parameters

Prefix a parameter name with * when its module should capture the remaining path.

// src/app/docs/path.rs contributes /docs/{*path}.
module_param!(*path);

The declaration emits a CatchAll segment override. The module must be the last served segment, and the catch-all matches at least one segment.

Handlers read an unparsed catch-all as CatchAllSegments or add a segment type to read a parsed slice.

See the path_param! macro reference for value shapes, construction, parsing, and errors.

A manual segment!(kind = CatchAll) capture remains available through raw_path_params. Its value carries the encoded tail, with / separators intact, next to its separately decoded segments.

Query parameters

Query parameters do not affect module-derived paths. Declare a named-field struct with #[query_params], then read it from any handler that takes cx: &Cx.

#[query_params(error = bad_request)]
struct PostsQuery {
    page: Option<u32>,
    q: Option<String>,
}

// In src/app/posts.rs, this serves /posts and accepts
// requests such as /posts?page=2&q=rust.
#[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_params] derives serde::Deserialize. Use Option<T> for optional keys. Parsing occurs once per request and returns a reference to the memoized struct.

Segment overrides

segment!(...) changes the enclosing module's segment. It accepts kind and rename, each at most once:

DeclarationResult
none in blog_posts/blog-posts
segment!(rename = "articles")/articles
segment!(kind = Group)no served URL segment
segment!(kind = Param, rename = "id")/{id}
segment!(kind = CatchAll, rename = "path")/{*path}

Static is the default kind for regular modules. Group is the default for modules whose names start with _. A rename is used as written; Hearth does not kebab-case it.

module_param! already sets the module's segment to Param or CatchAll. Use either module_param! or segment! in a module, not both. Using segment! alone captures the parameter but does not create a type for reading it with path_param::<T>(cx).

Groups

A module whose name starts with _ contributes a logical group segment but no served URL segment. Layouts and layers still use the group when matching descendants.

app.rs
app/
  _marketing.rs        # layout for this group
  _marketing/
    pricing.rs         # /pricing
    features.rs        # /features
  _docs.rs             # a different layout
  _docs/
    getting_started.rs # /getting-started

The two groups can apply different layouts to top-level URLs.

Use an explicit kind when the module name should not select the default:

use hearth::router::segment;
// src/app/marketing.rs: hide `marketing` from served URLs.
segment!(kind = Group);
use hearth::router::segment;
// src/app/_internal.rs: serve the module at /internal.
segment!(kind = Static);

Group names remain part of Hearth's logical paths. A layout or layer in _marketing applies only to descendants of _marketing, even though the group name is absent from request URLs.

Explicit absolute paths

Adding an absolute path string to #[page], #[layout], #[layer], or #[route] disables module path derivation for that item. segment! declarations do not affect explicit absolute paths.

module_router!() discovers module-derived handlers. Register an absolute-path handler by name:

use hearth::router::module_router;
#[page("/legacy")]
async fn legacy() -> Result<impl View> {
    Ok(view! { <h1>"Legacy"</h1> })
}

pub fn router() -> hearth::router::Router {
    module_router!()
        .page(legacy)
        .build()
}

To register all explicit-path handlers at once, call .discover() on the returned builder instead, as described in "Registering everything else".

Conflicts

Handlers in one module share a derived path. They may serve different HTTP methods, but overlapping methods at the same served path are rejected when the router is built. A specific-method route may share a path with a * route and takes precedence.

module_router! rejects two module-derived layouts or two module-derived layers at the same logical path because link-time discovery does not define their order. To run several layers at one path, register them explicitly with RouterBuilder::layer, which gives their order meaning.