How to Use the module_router! Macro for Automatic Route Discovery in Topcoat
The module_router! macro in Topcoat builds a complete router at compile-time by traversing your Rust module hierarchy, automatically mapping each module to a URL path segment while letting handlers inherit the derived route prefix.
The module_router! macro in the tokio-rs/topcoat repository eliminates boilerplate route registration by deriving your application's URL structure directly from your Rust module tree. This declarative approach ensures that your file system organization automatically determines your API surface, with each module contributing a path segment converted to kebab-case by default.
Setting Up the module_router! Macro
Place the macro invocation at the root of your route tree where it can access the module hierarchy. The root module maps to /, and calling module_router!() returns a RouterBuilder that allows method chaining before finalizing with .build().
The macro requires the discover feature, which the top-level topcoat crate enables by default. With this feature enabled, you can call .discover() on the builder to register items that are not module-derived, such as explicit-path handlers, fonts, and static assets.
// src/app.rs
pub fn router() -> topcoat::router::Router {
topcoat::router::module_router!().build()
}
For a production setup that includes static assets and automatic discovery of explicit-path items, chain the discovery method before building:
use topcoat::{
asset::{AssetBundle, RouterBuilderAssetExt},
router::{Router, RouterBuilderDiscoverExt},
};
pub fn router() -> Router {
topcoat::router::module_router!()
.discover() // registers explicit-path handlers, fonts, etc.
.assets(AssetBundle::load().unwrap())
.build()
}
How Module Hierarchy Maps to URL Paths
Each sub-module contributes exactly one path segment to the final route. Topcoat automatically converts module names to kebab-case for URL-friendly formatting.
| Module | Route Path |
|---|---|
app |
/ |
app::about |
/about |
app::blog_posts |
/blog-posts |
app::settings::profile |
/settings/profile |
The function name inside a module does not affect the final path. If two handlers exist in the same module, they share the same base path derived from that module's name. According to the implementation in crates/topcoat-router/docs/module_router.md, this design ensures that the module structure—not individual function names—determines your routing table.
Dynamic Path Parameters and Catch-All Routes
Use the path_param! macro (or alternatively segment!) inside a module to convert its static segment into a dynamic parameter ({param}) or a catch-all wildcard ({*param}). This is implemented in crates/topcoat-router/src/module/router.rs.
// src/app/posts/post_id.rs
use topcoat::{Result, context::Cx, router::{page, path_param}, view::view};
path_param!(post_id: u64, error = bad_request);
#[page]
async fn post(cx: &Cx) -> Result {
let id = path_param::<PostId>(cx)?;
view! { <h1>"Post " (id)</h1> }
}
The path_param! declaration defines the type and error handling for the parameter, which is then extracted from the request context in the handler. This allows the post_id module to map to routes like /posts/123 while automatically validating the parameter type.
Combining Automatic Discovery with Explicit Routes
Handlers that specify an explicit path string using #[page("/legacy")] or #[route("/api/v1")] opt-out of module-derived routing entirely. You can still register these handlers on the same builder either individually or by calling .discover() to scan for all attributed items that are not part of the module tree.
This architecture, detailed in crates/topcoat-router/docs/module_router.md, allows you to mix convention-based routing (module hierarchy) with configuration-based routing (explicit paths) in the same application.
Complete Working Example
The runtime demo in examples/runtime/src/main.rs demonstrates a full implementation combining module-derived routes, static assets, and explicit discovery:
// examples/runtime/src/main.rs
#[tokio::main]
async fn main() {
topcoat::start(
module_router!() // derive routes from the module tree
.assets(AssetBundle::load().unwrap())
.discover() // add explicit-path handlers, assets, etc.
.build(),
)
.await
.unwrap();
}
Individual pages within the module hierarchy, such as the counter example in examples/runtime/src/counter.rs, use the standard attributes without path arguments to inherit their parent's route:
#[layout]
async fn root_layout(slot: Result) -> Result {
view! { <html><body>(slot?)</body></html> }
}
#[page]
async fn home() -> Result {
view! { <h1>"Home"</h1> }
}
Summary
- Root placement: Invoke
module_router!()at your route tree root to return aRouterBuilderfor chaining with.build(). - Automatic mapping: Each module contributes one kebab-case path segment; handlers inherit this prefix via
#[page],#[layout],#[layer], or#[route]attributes. - Shared paths: Multiple handlers in the same module share the identical base path, as the function name does not influence routing.
- Dynamic segments: Use
path_param!to create parameterized routes like/posts/{id}with type-safe extraction. - Explicit opt-out: Handlers with explicit path strings bypass module discovery but can be registered via
.discover()alongside automatic routes. - Implementation details: The macro logic resides in
crates/topcoat-router/src/module/router.rs, while discovery extensions are defined incrates/topcoat-router/src/router.rs.
Frequently Asked Questions
Does module_router! work with API routes and layouts?
Yes. The #[route] attribute for API endpoints and the #[layout] attribute for nested UI layouts work identically to #[page]. They automatically inherit the module-derived path prefix. According to the source in crates/topcoat-router/docs/module_router.md, these attributes participate in the same compile-time module walk that builds the route tree.
What happens if I place two page handlers in the same module?
Both handlers will share the exact same base path derived from the module name. The function names themselves do not create distinct routes. To differentiate endpoints, either place handlers in separate sub-modules or use explicit path attributes like #[page("/unique")] to override the automatic routing for that specific handler.
How do I opt out of automatic route discovery for specific handlers?
Apply an explicit path string to the attribute, such as #[page("/legacy-path")] or #[route("/api/status")]. As documented in crates/topcoat-router/docs/module_router.md#explicit-paths, this opts the handler out of module-derived routing. You must then register it manually or call .discover() on the RouterBuilder to include it in the final router.
Where is the module_router! macro implemented?
The procedural macro implementation and the RouterBuilder return type are located in crates/topcoat-router/src/module/router.rs. The discovery extensions that enable .discover() are implemented in crates/topcoat-router/src/router.rs as part of the RouterBuilderDiscoverExt trait.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →