How Module-Based Automatic Routing Works in Topcoat: A Complete Guide

TLDR: Topcoat's module_router! macro transforms your Rust module hierarchy into a type-safe URL routing table by mapping compile-time module paths to route paths, automatically applying kebab-case conversion while supporting explicit overrides through the segment! macro.

The tokio-rs/topcoat framework implements module-based automatic routing to eliminate boilerplate configuration by deriving your application's URL structure directly from its module tree. This convention-over-configuration approach uses compile-time introspection via module_path!() and procedural macros to discover handlers, converting filesystem organization into deterministic route definitions. Understanding this pipeline is essential for building scalable Topcoat applications without manual route registration.

The ModuleRouterBuilder Pipeline

At the core of this system is the ModuleRouterBuilder struct, implemented in crates/topcoat-router/src/module/router.rs. When you invoke the module_router! macro (defined in crates/topcoat-router/src/module/mod.rs), it instantiates this builder and executes a deterministic five-stage pipeline to construct your routing table.

Root Detection via module_path!()

The macro establishes the routing root by calling ModuleRouterBuilder::new(root_module_path), where root_module_path is the compile-time string value of module_path!() from the module invoking the macro. This establishes the baseline prefix that all subsequent route calculations strip away, ensuring that module paths are translated relative to your application's entry point.

Path Translation and Segment Types

For each discovered module, the module_path_to_path function processes the module path components through the following rules:

  • User overrides: Checks for segment! macro declarations that modify the SegmentKind—valid kinds are Static, Group, Param, and CatchAll
  • Default heuristics: Module names with a leading underscore become Group segments (preserved in the path structure but omitted from the final URL), while standard identifiers become Static segments
  • Kebab-case conversion: Static segment names are automatically converted to kebab-case (e.g., blog_posts becomes blog-posts)

The resulting list of PathSegment structs is assembled into a PathBuf. For groups, this buffer generates the URL-matching pattern via the to_matchit_path conversion, as verified in the test suite.

Resource Discovery via Inventory

When the discover feature is enabled, the builder walks the inventory tables populated at link time by the #[page], #[layout], #[route], and #[layer] procedural macros:

  1. discover_segments registers every Segment declared with segment! to ensure overrides are available before path calculation
  2. discover_pages, discover_layouts, discover_routes, and discover_layers register the corresponding resources, each invoking module_path_to_path to compute the concrete route path

Registration and Safety Constraints

The computed routes are handed to the underlying RouterBuilder (the core routing engine) via the methods page, layout, route, and layer. The builder enforces two critical safety invariants:

  • Ordering requirement: Segment overrides must be registered before any pages or layouts—the segment method contains an assert! that validates this sequence
  • Uniqueness constraint: Duplicate paths for discovered layouts or layers trigger a panic, preventing ambiguous nesting configurations

Configuring Routes with segment! Overrides

While automatic routing handles conventional structures, the segment! macro provides granular control when you need dynamic parameters or custom naming:

// Convert a static module name into a URL parameter
segment! {
    "app::users::id" => kind: Param
}

With this override, the module path app::users::id resolves to the URL pattern /users/{id} instead of the static /users/id.

Group segments organize your code without affecting the URL structure:

// `_marketing` is a Group segment—present in the module tree but stripped from URLs
pub mod _marketing {
    pub mod pricing; // Accessible at "/pricing", not "/marketing/pricing"
}

Complete Implementation Example

Define your router at the application root:

// examples/module-router/src/app.rs
use topcoat::router::module_router;
use topcoat::router::module::Segment;

pub fn app() -> topcoat::Router {
    // `module_router!` builds from the current module tree
    topcoat::router::module_router!()
        // Optional: manually inject a segment override before discovery
        .segment(Segment::new("app::admin", Some(SegmentKind::Group), None))
        .discover()    // Auto-discover all #[page], #[layout], and #[route] items
        .build()
}

Create pages anywhere in your module tree:

// src/pages/about.rs
#[page]  // Registers this function with the inventory system
pub async fn get(cx: &Cx) -> impl IntoResponse {
    topcoat_view::html! { <h1>"About"</h1> }
}

Topcoat automatically maps the module path app::pages::about to the route /pages/about, applying kebab-case formatting and respecting any segment! overrides encountered along the path.

Summary

  • module_router! initiates automatic routing by creating a ModuleRouterBuilder that uses module_path!() to establish the root context.
  • module_path_to_path translates Rust module paths to URL segments, defaulting to kebab-case static segments while treating underscore-prefixed modules as groups.
  • The inventory system enables link-time discovery of #[page], #[layout], #[route], and #[layer] items without explicit registration.
  • segment! overrides allow precise control over segment types (Param, CatchAll, etc.) and must be declared before resource discovery.
  • Safety checks prevent duplicate layout paths and enforce correct macro ordering, failing at compile time rather than runtime.

Frequently Asked Questions

How does Topcoat convert module names to URL paths?

Topcoat uses the module_path_to_path function in crates/topcoat-router/src/module/router.rs to strip the root module prefix, walk each ::-separated component, and apply transformation rules. Static segments are converted to kebab-case, while group segments (marked with a leading underscore) are omitted from the final URL but preserved in the internal path structure.

What is the difference between Static and Group segments?

Static segments appear directly in the URL (e.g., blog_posts becomes blog-posts). Group segments (created by prefixing a module name with an underscore) organize your code hierarchically but are stripped from the URL pattern during the to_matchit_path conversion, allowing you to nest modules without creating nested URL paths.

Can I use dynamic parameters in module-based routing?

Yes. Use the segment! macro to override a module's default SegmentKind with Param or CatchAll. For example, declaring segment! { "app::users::id" => kind: Param } converts that module into a URL parameter {id}, enabling routes like /users/123 to map to the app::users::id module.

When should I use manual registration versus automatic discovery?

Use automatic discovery (the .discover() method) for convention-over-configuration applications where your file structure matches your URL structure. Use manual registration when you need to conditionally include modules, apply runtime logic to route construction, or when working with the segment! macro to inject specific Segment configurations before the discovery phase runs.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →