Have you ever clicked a link in a Blazor app and got a full page reload or, worse, a blank page? That’s not a ghost in the machine – that’s routing done wrong. Let’s fix it for good.
You’ll learn how to:
- Map routes with
@pageand route constraints. - Pass data with route params and query strings.
- Move the user with
NavigationManager(programmatically) without full reloads. - Use
<NavLink>so active links highlight on their own. - Add auth checks with
AuthorizeRouteViewand custom guards. - Handle 404s, wildcards, and route fallbacks.
- Test routes with bUnit.
I’ll keep it hands‑on with real code you can paste into your app. When I say “works in my project,” I mean it.
Quick setup (so examples just run)
The snippets below work in Blazor Server and Blazor WebAssembly (hosted or standalone). I’ll assume the standard App.razor router:
<!-- App.razor -->
<CascadingAuthenticationState>
<Router AppAssembly="typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
</Found>
<NotFound>
<LayoutView Layout="typeof(MainLayout)">
<h1>Not found</h1>
<p>Sorry, there’s nothing here.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>Tip: In .NET 8 you can also use
AuthorizeRouteView(shown later) to wire in auth directly at the router.
1) Route basics with @page
A component becomes routable when you add @page with a template.
@page "/todos"
<h3>Todo list</h3>Multiple templates are fine (handy for short and long forms of the same path):
@page "/orders"
@page "/orders/{id:int}"
@code {
[Parameter] public int? Id { get; set; }
}Route constraints (type filters) help Blazor pick the right component:
int,long,decimal,guid,bool,datetime- Example:
@page "/users/{userId:guid}"
If a constraint fails, the route doesn’t match. That often explains “why did I land on NotFound?”.
Catch‑all (grab the rest of the path):
@page "/files/{*path}"
@code { [Parameter] public string? Path { get; set; } }Great for virtual folders or log viewers.
Optional params? Use two @page lines (Blazor doesn’t support optional params in one template):
@page "/products"
@page "/products/{id:int}"
@code { [Parameter] public int? Id { get; set; } }2) Read route params in a component
Use [Parameter] and matching names:
@page "/orders/{id:int}"
<h3>Order @Id</h3>
@code {
[Parameter] public int Id { get; set; }
}Need custom parsing or side effects when the param changes? Hook a lifecycle method:
@page "/reports/{year:int}/{month:int}"
<h3>Report @year-@month</h3>
@code {
[Parameter] public int year { get; set; }
[Parameter] public int month { get; set; }
protected override async Task OnParametersSetAsync()
{
// load data each time the url changes to a new year/month
await LoadAsync(year, month);
}
}Gotcha: Parameter names are case‑insensitive in the template but case‑sensitive in your C# code.
3) Query strings with [SupplyParameterFromQuery]
Map query keys to parameters without hand‑parsing the URI.
@page "/search"
<h3>Search</h3>
<input @bind="q" placeholder="term" />
<button @onclick="Apply">Apply</button>
<p>Term: @q</p>
<p>Page: @pageNumber</p>
@code {
[SupplyParameterFromQuery] public string? q { get; set; }
[SupplyParameterFromQuery(Name = "page")] public int pageNumber { get; set; } = 1;
private void Apply()
{
// push query back into the url
var uri = QueryHelpers.AddQueryString("/search", new Dictionary<string, string?>
{
["q"] = q,
["page"] = pageNumber.ToString()
});
Nav.NavigateTo(uri); // see Nav wrapper below
}
[Inject] private NavigationManager Nav { get; set; } = default!;
}
QueryHelperslives inMicrosoft.AspNetCore.WebUtilities.
4) Links that know when they are active
Use <NavLink> instead of <a> to get an active CSS class out of the box.
<NavLink href="/" Match="NavLinkMatch.All">Home</NavLink>
<NavLink href="/orders">Orders</NavLink>Match="All"marks the link active only on the exact path.- Without it, the link is active for any child path (e.g.,
/orders/123).
Style the active link:
.navbar a.active { font-weight: 600; border-bottom: 2px solid currentColor; }5) Move the user programmatically with NavigationManager
Sometimes a button needs to change the page without a link. Inject NavigationManager and call NavigateTo.
@code {
[Inject] private NavigationManager Nav { get; set; } = default!;
void GoToOrder(int id) => Nav.NavigateTo($"/orders/{id}");
}Do a soft vs hard change
Nav.NavigateTo("/reports", forceLoad: false); // soft SPA change (no full reload)
Nav.NavigateTo("/reports", forceLoad: true); // hard reload the documentReplace current history entry (avoid back button ping‑pong):
Nav.NavigateTo("/login", replace: true);Listen for URI changes
@code {
[Inject] NavigationManager Nav { get; set; } = default!;
private string? _current;
protected override void OnInitialized()
{
_current = Nav.Uri;
Nav.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
_current = e.Location; // react to changes
StateHasChanged();
}
public void Dispose() => Nav.LocationChanged -= OnLocationChanged;
}I often use this to cancel in‑flight loads when the user moves away.
6) Route guards: auth and custom checks
a) Auth with AuthorizeRouteView
Protect pages based on roles/policies right in App.razor:
<Router AppAssembly="typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="typeof(MainLayout)">
<p>Nothing here.</p>
</LayoutView>
</NotFound>
</Router>Then mark components:
@attribute [Authorize]
@* or *@
@attribute [Authorize(Roles = "Admin")]b) Custom “can I enter?” check
For non‑auth rules (e.g., feature flags, tenant state), I keep a small guard base class:
public abstract class GuardedComponentBase : ComponentBase
{
[Inject] protected NavigationManager Nav { get; set; } = default!;
protected virtual ValueTask<bool> CanEnterAsync() => ValueTask.FromResult(true);
protected override async Task OnInitializedAsync()
{
if (!await CanEnterAsync())
{
Nav.NavigateTo("/forbidden", replace: true);
}
}
}Use it in a page:
@page "/beta"
@inherits GuardedComponentBase
<h3>Beta area</h3>
@code {
[Inject] IFeatureFlags Flags { get; set; } = default!;
protected override async ValueTask<bool> CanEnterAsync()
=> await Flags.IsEnabledAsync("beta");
}7) Leaving a page with unsaved work (NavigationLock)
Warn users before they lose changes:
@page "/edit/{id:int}"
@using Microsoft.AspNetCore.Components.Routing
<NavigationLock ConfirmExternalNavigation="true"
OnBeforeInternalNavigation="OnBeforeInternalNavigation" />
<EditForm Model="_model" OnValidSubmit="SaveAsync"> ... </EditForm>
@code {
private bool _dirty;
private ValueTask OnBeforeInternalNavigation(LocationChangingContext ctx)
{
if (_dirty)
{
// show your dialog here; cancel and do it later if user chooses to stay
ctx.PreventNavigation();
}
return ValueTask.CompletedTask;
}
}This has saved me from many “where did my form go?” bug reports.
8) 404s, wildcards, and fallbacks
You already saw <NotFound> in App.razor. You can go further and mount a special component that shows helpful links, a search box, and a log token.
Wildcard routing example (serve files from storage):
@page "/docs/{*slug}"
@code { [Parameter] public string? slug { get; set; } }Deep links with hosting
- Blazor Server: the default endpoint handles deep links. Nothing to add.
- Blazor WASM: make sure the server (or static host) serves
index.htmlfor unknown paths (SPA fallback). In ASP.NET Core host, that’sMapFallbackToFile("index.html").
9) URIs, base path, and relative links
If your app runs under a sub‑path like /app, set the base tag in wwwroot/index.html (WASM) or _Host.cshtml (Server):
<base href="/app/" />Then use relative links: href="orders" instead of href="/orders". That way your app works both at root and under a sub‑path.
To build a URI safely (with query), use QueryHelpers as shown earlier. Avoid string concatenation for query strings.
10) Layouts and nested sections
Routes render into a layout by default (see DefaultLayout). You can override per page:
@layout AdminLayoutOr set a layout attribute on a base class:
[Layout(typeof(AdminLayout))]
public abstract class AdminPageBase : ComponentBase { }Then @inherits AdminPageBase in pages that need the admin chrome.
11) Patterns that keep routes tidy
A few rules I follow in real projects:
- Centralize route strings in one static class to avoid typos:
public static class Routes
{
public const string Home = "/";
public const string Orders = "/orders";
public static string Order(int id) => $"/orders/{id}";
}- Prefer
NavLinkover<a>inside the app. Reserve<a>for external links. - Use
replace:trueafter login/logout so the back button doesn’t carry you to a stale page. - Handle unknown ids (e.g.,
/orders/99999) with a friendly message, not a blank page. - Avoid heavy work in
OnInitializedif it depends on route params. UseOnParametersSetAsyncso a param change reloads data. - Be strict with constraints to reduce ambiguous matches.
12) Testing routes with bUnit
You can assert that a path renders the expected component.
using Bunit;
using Microsoft.AspNetCore.Components.Routing;
[Fact]
public void Orders_id_route_renders_details()
{
using var ctx = new TestContext();
ctx.Services.AddSingleton<NavigationManager>(new TestNavigationManager());
var cut = ctx.RenderComponent<Router>(ps => ps
.Add(p => p.AppAssembly, typeof(App).Assembly)
.Add<Found>(p => p.Found, parms =>
parms.AddChildContent<RouteView>(rvps => rvps
.Add(p => p.DefaultLayout, typeof(MainLayout))))
.Add<NotFound>(p => p.NotFound, parms =>
parms.AddChildContent("Not found"))
);
ctx.Services.GetRequiredService<NavigationManager>()
.NavigateTo("/orders/42");
cut.MarkupMatches(markup => markup.Contains("Order 42"));
}This gives you safety when refactoring paths.
Visual cheat sheet
+---------------------+ click/link/programmatic +-------------------+
| /orders | ----------------------------------> | OrdersList.razor|
+---------------------+ +-------------------+
| |
| /orders/123 | reads [Parameter] Id=123
v v
+---------------------+ +-------------------+
| /orders/{id:int} | ---- constraint match (int) ----> | OrderDetails.razor|
+---------------------+ +-------------------+Print this section and stick it near your desk. It helps junior devs a lot.
FAQ: common routing questions
Inject NavigationManager and use Nav.Uri.
<NavLink> and <a>?<NavLink> updates the active class and uses SPA‑style changes. <a> should be used for external links or when you need a full reload.
[Parameter]?Yes, with [SupplyParameterFromQuery]. See the search example above.
Call Nav.NavigateTo(targetPath). If you don’t want the last page in history, pass replace:true.
Add NavigationLock and handle OnBeforeInternalNavigation to show a confirm dialog.
/orders and /orders/{id} components?Not always. You can put both views in one component and switch the view based on whether Id is set.
You likely used <a href="/path"> or forceLoad:true. Use <NavLink> or a button that calls Nav.NavigateTo without forceLoad.
Not for components. I keep a Routes static class with helpers (shown above) to reduce string reuse.
Subscribe to Nav.LocationChanged and react there.
Serve index.html for unknown paths so the router can take over.
Conclusion: solid, simple routing for real apps
If a link lands on the wrong page, if the back button behaves weird, or if a form loses data, users lose trust. With the tools above – clear @page templates, constraints, query mapping, <NavLink>, NavigationManager, AuthorizeRouteView, and NavigationLock – you can build smooth page changes that feel instant and safe. Copy a snippet, try it in your app today, and tell me which tip saved you the most time. Got a trick of your own? Drop it in the comments – I read them all.
