A tree-based routing library for Blazor — works with WASM, Server, and Web App
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 →
dotnet add package BetterRouteReplace 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.
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>.
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.
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.
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.
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.
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 redirectingAliases 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:
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.
<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.
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).
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.
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.
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 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.
Routes are validated eagerly when BetterRouter first renders. The following are caught with clear error messages:
- Duplicate route names —
Namemust be unique across the tree - Unbound redirect params —
:paramreferences inRedirectTomust be available from the current route or its ancestors - Mutual exclusivity — cannot combine
RedirectTo/RedirectToFactorywithComponentorAliases - Missing target — every route must have a
Component, a named component, a redirect, or children
Set the NotFound parameter on BetterRouter to render a component when no route matches:
<BetterRouter Routes="@Routes" NotFound="typeof(NotFoundPage)" />| 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). |
| 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. |
| 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. |
| 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 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.csprojRequirements: .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.
BetterRoute processes a navigation in five stages:
- Definition — Consumer declares a tree of
RouteDefinitionrecords inApp.razor. - Compilation —
BetterRoutercompiles the tree intoCompiledRoutenodes (pre-segmented paths + child references) and aNamedRouteIndexon parameter change. Aliases are expanded into synthetic entries. - Matching —
RouteMatcher.TryMatchwalks the compiled tree. Literal segments are case-insensitive;:paramsegments capture URL-decoded values. Literal siblings take precedence over parameter siblings. - Guard Pipeline —
GuardPipeline.RunAsyncexecutes three phases: leave guards (deepest-first), globalBeforeEach, and per-routeBeforeEnter(root-out, new nodes only). Results can beContinue,Cancel, orRedirect. - Rendering — A successful match produces a
RouterStatecascaded to all components.RouterOutletadvances depth so nested components see their own slice of the matched chain.
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
The following features are designed but not yet implemented. See the linked design documents for details.
- Catch-All Segments —
*namesegment prefix to capture the remaining URL tail as a single string. - Route Transitions —
<RouterTransition>component for CSS-driven animated transitions between routes. - Scroll Restoration —
ScrollBehaviorhook onBetterRouterto control scroll position after navigation. - Lazy / Async-Loaded Components — on-demand assembly loading for routes to reduce initial bundle size.
MIT
Built with ❤️ for the Blazor community