BetterRoute 1.0.1

dotnet add package BetterRoute --version 1.0.1
                    
NuGet\Install-Package BetterRoute -Version 1.0.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="BetterRoute" Version="1.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="BetterRoute" Version="1.0.1" />
                    
Directory.Packages.props
<PackageReference Include="BetterRoute" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add BetterRoute --version 1.0.1
                    
#r "nuget: BetterRoute, 1.0.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package BetterRoute@1.0.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=BetterRoute&version=1.0.1
                    
Install as a Cake Addin
#tool nuget:?package=BetterRoute&version=1.0.1
                    
Install as a Cake Tool

BetterRoute

<p align="center"> <b>A tree-based routing library for Blazor — works with WASM, Server, and Web App</b> </p>

<p align="center"> <a href="https://github.com/wtfuii/BetterRoute/actions/workflows/ci.yml"> <img src="https://github.com/wtfuii/BetterRoute/actions/workflows/ci.yml/badge.svg" alt="Build Status"> </a> <a href="https://www.nuget.org/packages/BetterRoute"> <img src="https://img.shields.io/nuget/v/BetterRoute.svg?label=NuGet" alt="NuGet"> </a> <img src="https://img.shields.io/badge/net-10.0-blueviolet" alt=".NET 10.0"> <img src="https://img.shields.io/badge/Blazor-WASM_|Server|_Web_App-512bd4" alt="Blazor WASM | Server | Web App"> <a href="LICENSE"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License"> </a> <a href="https://wtfuii.github.io/BetterRoute"> <img src="https://img.shields.io/badge/demo-live-0b9642" alt="Live Demo"> </a> </p>

BetterRoute is an alternative to Blazor's built-in Router that replaces flat route tables with a declarative tree of RouteDefinition records. Routes are defined as nested nodes — mirroring the component hierarchy they render — which unlocks parent/child relationships, cascading state, named outlets, redirects, aliases, navigation guards, and programmatic navigation by route name.

🚀 See the live demo → — a Blazor WASM app built with BetterRoute. Browse the source →


Table of Contents


Quick Start

1. Install the package

dotnet add package BetterRoute

2. Add the router to App.razor

Replace the default <Router> with <BetterRouter> and define your route tree:

@using BetterRoute.Routing

<BetterRouter Routes="@Routes" NotFound="typeof(NotFoundPage)" />

@code {
    private static readonly IReadOnlyList<RouteDefinition> Routes =
    [
        new RouteDefinition("", typeof(Home)),
        new RouteDefinition("users", typeof(UsersLayout), Children:
        [
            new RouteDefinition("", typeof(UsersIndex)),
            new RouteDefinition(":userId", typeof(UserLayout), Children:
            [
                new RouteDefinition("", typeof(UserOverview)),
                new RouteDefinition("profile", typeof(UserProfile)),
                new RouteDefinition("posts/:postId", typeof(UserPost)),
            ]),
        ]),
    ];
}

No DI registration is needed — the router is used directly as a Blazor component.

Replace plain <a> tags with <RouteLink> to prevent full-page reloads:

@* Before: full page reload *@
<a href="users/42/profile">Profile</a>

@* After: client-side navigation *@
<RouteLink Href="users/42/profile">Profile</RouteLink>

@* Named-route navigation *@
<RouteLink Name="user.post" Params="new { userId = 42, postId = 7 }">Post 7</RouteLink>

RouteLink automatically intercepts clicks, navigates client-side, and renders a proper <a> tag. Pass additional attributes like class or style and they are splatted onto the rendered element.


Core Concepts

Tree-Based Route Definitions

Routes are a nested structure of RouteDefinition records, not a flat list of templates:

new RouteDefinition("users", typeof(UsersLayout), Children:
[
    new RouteDefinition("", typeof(UsersIndex)),         // /users
    new RouteDefinition(":userId", typeof(UserLayout), Children:  // /users/:userId
    [
        new RouteDefinition("profile", typeof(UserProfile)),    // /users/:userId/profile
        new RouteDefinition("posts/:postId", typeof(UserPost)), // /users/:userId/posts/:postId
    ]),
])

This tree mirrors your component hierarchy. A parent route renders a layout component; children render inside <RouterOutlet>.

Layout as Routing

There is no separate layout system. Nested layouts are intermediate routes whose component contains a <RouterOutlet>:

@* UsersLayout.razor *@
<div class="users-shell">
    <h1>Users</h1>
    <RouterOutlet />   @* renders UsersIndex, UserLayout, etc. *@
</div>
@* UserLayout.razor *@
@code {
    [CascadingParameter] public RouterState State { get; set; } = default!;
}
<div class="user-shell">
    <h2>User @State.GetParameter("userId")</h2>
    <RouterOutlet />   @* renders UserOverview, UserProfile, UserPost *@
</div>

State cascades down via CascadingValue with IsFixed="false", so components re-render on every navigation.


Features

Path Parameters

Prefix a segment with : to capture it as a parameter:

new RouteDefinition("users/:userId/posts/:postId", typeof(UserPost))
// /users/42/posts/7 → Parameters["userId"] = "42", Parameters["postId"] = "7"

Parameters from every level of the matched chain are merged into RouterState.Parameters. Deeper levels override shallower ones with the same name. Literal segments take precedence over parameter segments when paths overlap.

Redirects

Static Redirects
new RouteDefinition("profile", RedirectTo: "/users/:userId/profile")
// /profile?userId=42 → /users/42/profile

:param placeholders are substituted from captured parameters. Relative paths (../sibling, ./child) are resolved against the current URL. Query strings and fragments from the original URL are preserved unless the target defines its own.

Dynamic Redirects
new RouteDefinition("dashboard", RedirectToFactory: state =>
{
    return state.GetParameter("role") switch
    {
        "admin" => "/admin/dashboard",
        _       => "/user/dashboard",
    };
})

The factory receives a provisional RouterState and returns the redirect target (or null to signal not-found).

Redirects are mutual-exclusive with Component and Aliases. Redirect hops are capped at 10 to prevent infinite loops.

Aliases

Alternative paths that render the same component without changing the URL:

new RouteDefinition("", typeof(Home), Aliases: ["home", "index"])
// /home and /index both render Home without redirecting

Aliases are expanded at compile time into synthetic route nodes that share the same Component and Children by reference.

Three layers of guards run in order on every navigation:

1. Per-Component Leave Guards (IBeforeRouteLeave)
public class EditForm : ComponentBase, IBeforeRouteLeave
{
    [CascadingParameter] public GuardRegistrar GuardRegistrar { get; set; } = default!;

    protected override void OnInitialized()
        => GuardRegistrar.Register(this, depth: 0);

    public async ValueTask<GuardResult> CanLeaveAsync(NavigationContext ctx, CancellationToken ct)
    {
        if (HasUnsavedChanges)
        {
            var confirmed = await JS.InvokeAsync<bool>("confirm", "Discard changes?");
            return confirmed ? GuardResult.Ok : GuardResult.Stop;
        }
        return GuardResult.Ok;
    }

    public void Dispose() => GuardRegistrar.Unregister(this);
}

Leave guards are called deepest-first.

2. Global Guard (BeforeEach)
<BetterRouter Routes="@Routes" BeforeEach="@CheckAuth" />

@code {
    private async ValueTask<GuardResult> CheckAuth(NavigationContext ctx, CancellationToken ct)
    {
        if (ctx.To.Path.StartsWith("/admin") && !IsLoggedIn)
            return GuardResult.To("/login");
        return GuardResult.Ok;
    }
}

Runs after all leave guards pass.

3. Per-Route Enter Guards (BeforeEnter)
new RouteDefinition("admin", typeof(AdminLayout), Children: [...])
{
    BeforeEnter = async (ctx, ct) =>
    {
        if (!HasAdminRole)
            return GuardResult.Stop;
        return GuardResult.Ok;
    }
}

Runs only for route nodes that are new to the matched chain — parents reused from the previous navigation are skipped (detected via reference equality).

Guard Results

All guards return a GuardResult:

Factory Returns Effect
GuardResult.Ok Continue Approve the navigation
GuardResult.Stop Cancel Cancel and restore the previous URL
GuardResult.To(url) Redirect Navigate to a different URL

Guard exceptions are caught and forwarded to BetterRouter.OnNavigationError.

Named Routes

Assign a Name to any route for programmatic navigation:

new RouteDefinition("users/:userId/posts/:postId", typeof(UserPost), Name: "user.post")
@* Navigate from anywhere with access to RouterState *@
@code {
    [CascadingParameter] public RouterState State { get; set; } = default!;

    void GoToPost(int userId, int postId)
    {
        // Dictionary overload
        State.NavigateTo("user.post", new Dictionary<string, string>
        {
            ["userId"] = "42",
            ["postId"] = "7"
        });

        // Anonymous object overload (uses Convert.ToString with InvariantCulture)
        State.NavigateTo("user.post", new { userId = 42, postId = 7 });

        // When parameters is null, reuses current Parameters
        State.NavigateTo("user.post"); // keeps current userId, navigates to sibling
    }

    string GetPostUrl(int userId, int postId)
    {
        return State.ResolveUrl("user.post", new { userId, postId });
    }
}

Names use a dotted convention ("user.post") and must be unique across the entire route tree. Extra keys not appearing in the template are appended as query string parameters.

Named Outlets

A route can declare multiple named components via the Components dictionary:

new RouteDefinition("users/:userId/search", typeof(UserSearch),
    Components: new Dictionary<string, Type>
    {
        ["sidebar"] = typeof(UserSidebar),
    })

Render them with named <RouterOutlet> elements:

@* In UserLayout.razor *@
<div style="display: flex; gap: 16px;">
    <main style="flex: 1;">
        <RouterOutlet />             @* renders UserSearch *@
    </main>
    <aside style="width: 220px;">
        <RouterOutlet Name="sidebar" />  @* renders UserSidebar *@
    </aside>
</div>

Named outlets render as siblings (same depth) rather than children. MatchedRoute.AllComponents merges the default component (keyed "") with all named components.

Query Strings & Fragments

Query strings are automatically parsed and available on RouterState:

// URL: /users/42/search?q=blazor&sort=asc&tag=oss&tag=web
State.Query                          // { "q": ["blazor"], "sort": ["asc"], "tag": ["oss", "web"] }
State.GetQuery("q")                  // "blazor"
State.GetQueryValues("tag")          // ["oss", "web"]
State.Fragment                       // "section-2" (from #section-2)

QueryStringParser.Parse(string?) is public and can be used standalone. Bare keys (no =) map to an empty-string value, matching URLSearchParams behavior.

Compile-Time Validation

Routes are validated eagerly when BetterRouter first renders. The following are caught with clear error messages:

  • Duplicate route namesName must be unique across the tree
  • Unbound redirect params:param references in RedirectTo must be available from the current route or its ancestors
  • Mutual exclusivity — cannot combine RedirectTo/RedirectToFactory with Component or Aliases
  • Missing target — every route must have a Component, a named component, a redirect, or children

Not Found Handling

Set the NotFound parameter on BetterRouter to render a component when no route matches:

<BetterRouter Routes="@Routes" NotFound="typeof(NotFoundPage)" />

API Reference

RouteDefinition

Property Type Description
Path string Path template relative to parent. Segments prefixed with : capture parameters. Empty string "" for index/default child.
Component Type? Component type rendered when this route matches.
Children IReadOnlyList<RouteDefinition>? Nested routes matched against remaining URL segments.
Name string? Unique name for programmatic navigation (e.g. "user.post").
Components IReadOnlyDictionary<string, Type>? Named components for outlets. Default component is keyed "".
RedirectTo string? Static redirect template with :param substitution.
RedirectToFactory Func<RouterState, string?>? Dynamic redirect factory. Return null to signal not-found.
Aliases IReadOnlyList<string>? Alternative paths that render the same component without redirecting.
BeforeEnter NavigationGuard? Per-route enter guard (init-only property).

BetterRouter

Parameter Type Description
Routes IReadOnlyList<RouteDefinition> Root route definitions. Compiled on first render and when the reference changes.
NotFound Type? Component to render when no route matches.
BeforeEach NavigationGuard? Global guard that runs on every navigation between leave and enter guards.
OnNavigationError Action<Exception>? Callback invoked when a guard throws or a redirect loop is detected.

RouterState

Member Type Description
Matched IReadOnlyList<MatchedRoute> Full matched route chain from root to leaf.
Current MatchedRoute The matched route at the current rendering depth.
CurrentDepth int Zero-based index into Matched.
Parameters IReadOnlyDictionary<string, string> All path parameters merged across the chain.
Query IReadOnlyDictionary<string, IReadOnlyList<string>> Parsed query string values.
Url string Full absolute URL including origin.
Path string Relative path portion of the URL.
Fragment string? Fragment after #, or null.
GetParameter(key) string? Convenience accessor for Parameters.
GetQuery(key) string? First query value for a key, or null.
GetQueryValues(key) IReadOnlyList<string> All query values for a key.
ResolveUrl(name, params?) string Resolve a named route to its full URL.
NavigateTo(name, params?, replace?) void Navigate to a named route.

RouterOutlet

Parameter Type Description
Name string? When null, renders the default component at the next depth. When set, renders a named component at the same depth.
Parameter Type Description
Href string? Target path for client-side navigation. Mutually exclusive with Name.
Name string? Named route to navigate to. Uses RouterState.ResolveUrl for the href and RouterState.NavigateTo on click. Mutually exclusive with Href.
Params object? Parameters for named-route navigation. Accepts IReadOnlyDictionary<string, string> or an anonymous object. Extra keys become query string parameters.

A drop-in replacement for <a> that prevents full-page reloads by using client-side navigation. Any additional attributes (class, style, target, etc.) are splatted onto the rendered <a> element.


Build & Test

# Build the solution
dotnet build BetterRoute.sln

# Run all tests
dotnet test BetterRoute.Tests/BetterRoute.Tests.csproj

# Run a specific test class
dotnet test BetterRoute.Tests/BetterRoute.Tests.csproj --filter "FullyQualifiedName~RouteMatcherTests"

# Run the sample Blazor WASM app
dotnet run --project BetterRoute.Sample/BetterRoute.Sample.csproj

Requirements: .NET 10 SDK. The library targets net10.0 with the browser platform and depends on Microsoft.AspNetCore.Components.Web 10.0.x.

The test suite covers route matching, compile-time validation, navigation guards, named routes, named outlets, query string parsing, and redirect resolution. The library uses InternalsVisibleTo so tests can reach internal types.


Architecture

BetterRoute processes a navigation in five stages:

  1. Definition — Consumer declares a tree of RouteDefinition records in App.razor.
  2. CompilationBetterRouter compiles the tree into CompiledRoute nodes (pre-segmented paths + child references) and a NamedRouteIndex on parameter change. Aliases are expanded into synthetic entries.
  3. MatchingRouteMatcher.TryMatch walks the compiled tree. Literal segments are case-insensitive; :param segments capture URL-decoded values. Literal siblings take precedence over parameter siblings.
  4. Guard PipelineGuardPipeline.RunAsync executes three phases: leave guards (deepest-first), global BeforeEach, and per-route BeforeEnter (root-out, new nodes only). Results can be Continue, Cancel, or Redirect.
  5. Rendering — A successful match produces a RouterState cascaded to all components. RouterOutlet advances depth so nested components see their own slice of the matched chain.

Internal Pipeline

Navigation → Match URL → Redirect? → Build RouterState → Leave Guards → BeforeEach → Enter Guards → Commit → Render
                ↓              ↓           ↓                  ↓              ↓           ↓            ↓
           TryMatch()   Resolve/        params +           deepest-      global     per-route    CascadingValue
                        navigate        query +            first                                  + RouterOutlet
                                        fragment

Future Features

The following features are designed but not yet implemented. See the linked design documents for details.


License

MIT


<p align="center"> <sub>Built with ❤️ for the Blazor community</sub> </p>

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.1 119 6/14/2026
1.0.0 116 6/14/2026