Recipes
The other docs pages are organized by feature. This one is organized by problem - the things applications actually need, each answered with code you can paste in.
Access control
Protect a whole area, and return the user afterwards
Put the guard on a pathless group and every route inside it inherits the
check - one declaration instead of one per page, and no /protected segment in
the URL. Because the guard runs before the URL commits, the address bar never shows the
page the user wasn't allowed to see; stash the intended destination in the query so the
login page can send them back.
<Broute Group Path="" Guard="@RequireSignIn"> <Routes> <Broute Path="/orders" Component="@typeof(OrdersPage)" /> <Broute Path="/orders/{id:int}" Component="@typeof(OrderPage)" /> <Broute Path="/settings" Component="@typeof(SettingsPage)" /> </Routes> </Broute> private async ValueTask RequireSignIn(BrouterNavigationContext ctx) { if (await auth.IsSignedInAsync(ctx.CancellationToken)) return; // The URL never moved, so ctx.To is the page they meant to reach. var returnUrl = Uri.EscapeDataString(ctx.To.Path + ctx.To.Query); ctx.Redirect($"/login?returnUrl={returnUrl}"); }
For the plain [Authorize]-attribute case you don't need a guard at all - set
NotAuthorized on <Brouter> and the framework's own
AuthorizeRouteView handles policy evaluation. See
migration.
A real "unsaved changes" prompt
Three different exits need covering, and each has its own tool. The component-level lock is
the one that can show your dialog and await the answer - and it's the only one that
catches a parameter change on the same route (/edit/1 → /edit/2).
| The user... | Covered by |
|---|---|
| navigates to another page in the app | OnDeactivatingAsync (custom dialog) or a route LeaveGuard (silent veto) |
| navigates to the same page with different parameters | OnRenavigatingAsync - a LeaveGuard never fires here |
| closes the tab, reloads, or follows an external link | SetConfirmExternalNavigationAsync(true) - the browser's own dialog |
public partial class EditorPage : BrouterRouteBase { private bool _dirty; private async Task MarkDirtyAsync() { _dirty = true; await brouter.SetConfirmExternalNavigationAsync(true); // tab close / reload } protected override async Task OnDeactivatingAsync(BrouterRouteDeactivatingContext ctx) { if (_dirty is false) return; // The navigation is held open while the dialog is up. The token fires if a // newer navigation supersedes this one, so the dialog can dismiss itself. if (await ConfirmDiscardAsync(ctx.To.Path, ctx.CancellationToken) is false) ctx.Cancel(); } }
Data
Refresh the screen after a mutation
After a POST, the data behind the current page is stale. Don't re-navigate to the same URL - that runs guards, replays the whole pipeline and flashes pending UI. Revalidate instead: the URL stays, the current content stays on screen, and only the loaders re-run.
private async Task SaveAsync() { await http.PostAsJsonAsync("/api/orders", _draft); // This page's data is stale → re-run just this chain's loaders. await brouter.RevalidateAsync(); // Other pages' cached data is stale too → drop the whole loader cache // so the next visit anywhere re-fetches. brouter.ClearLoaderCache(); }
Inside the loader, ctx.IsRevalidation distinguishes the refresh from a real
visit - useful for skipping an analytics ping or showing a subtler spinner.
Switch tenant, impersonate, sign out - and leave nothing behind
Revalidation is the wrong tool when it isn't only the data that went stale: the
components themselves are holding the previous user's world - what they read once in
OnInitialized, their filters, their half-typed drafts. Re-navigating to the
current URL cannot clear that, because Blazor reuses a component whose route and parameters
didn't change (the built-in Router behaves the same way). Throw the instances
away instead.
private async Task SwitchTenantAsync(int tenantId) { await auth.SwitchTenantAsync(tenantId); brouter.ClearLoaderCache(); // nothing cached belongs to this tenant brouter.ClearKeepAlive(); // drop the retained (hidden) pages of the previous one await brouter.ReloadAsync(); // and rebuild the page on screen: guards + loaders re-run } // When only the instances are stale - the loaded data is tenant-agnostic - one call // does the same rebuild without running the pipeline at all: brouter.ClearKeepAlive(includeActive: true);
ReloadAsync() disposes the matched chain, evicts its cached results and matches
the URL again - so enter guards get to re-authorize the rebuilt page (a guard that now says
no should Redirect, not Cancel) and the loaders start from nothing.
It is not a navigation: the URL and history entry don't move,
OnNavigating/OnNavigated stay silent, no leave guard can veto it,
and loaders can tell it apart with ctx.IsReload. On sign-out, pair
ClearKeepAlive() with a navigation to the login page instead - there is no page
left to rebuild.
Make navigation feel instant
Two settings, applied together, remove most perceived latency: preload on intent so the
fetch starts while the pointer is still travelling toward the link, and a
StaleTime so Back/Forward and re-visits skip the network entirely.
// Program.cs - app-wide defaults builder.Services.AddBitBrouterServices(o => { o.DefaultLinkPreload = BrouterLinkPreload.Intent; // every link, on hover/focus o.DefaultLoaderStaleTime = TimeSpan.FromSeconds(30); });
@* ...or per link / per route where it matters most *@ <BrouterLink Href="/orders/42" Preload="BrouterLinkPreload.Intent">Order 42</BrouterLink> <Broute Path="/orders/{id:int}" Loader="@LoadOrder" StaleTime="@TimeSpan.FromMinutes(2)" Component="@typeof(OrderPage)" />
Use Viewport preloading for long lists (each link warms as it scrolls into
view) and reach for brouter.PreloadAsync(url) when you know where the user is
headed - warming the dashboard while the sign-in request is still in flight, for instance.
Don't let one slow query hold the page
Await only what the page can't render without; return everything else as an unawaited task. The route reveals as soon as the critical data lands, and the slow part streams into its own placeholder.
private async ValueTask<object?> LoadDashboard(BrouterNavigationContext ctx) { var summary = await api.GetSummaryAsync(ctx.CancellationToken); // blocks navigation return new Dashboard(summary, api.GetSlowReportAsync()); // streams in later }
<BrouterAwait Task="@Data.SlowReport"> <Pending><Skeleton /></Pending> <Resolved Context="report"><ReportView Value="@report" /></Resolved> <Error Context="ex"><p>Report unavailable.</p></Error> </BrouterAwait>
State that belongs in the URL
Filters, sorting and paging as query state
Query-as-state makes a filtered view shareable and bookmarkable - but only if changing one
filter doesn't clobber the others. NavigateWithQuery mutates the current query
functionally and preserves everything you don't touch. It replaces the history entry by
default, so paging through twenty pages leaves one Back step, not twenty.
// Next page - keeps ?sort, ?q and the fragment exactly as they were. brouter.NavigateWithQuery(q => q.Set("page", _page + 1)); // Changing a filter resets paging: Set(null) removes a key. brouter.NavigateWithQuery(q => q.Set("status", status).Set("page", null)); // Multi-value, for ?tag=a&tag=b - and push it so this one IS a Back step. brouter.NavigateWithQuery(q => q.SetAll("tag", _tags), replace: false);
Read the values back by binding them - [SupplyParameterFromQuery] for the
types the framework parses, [BrouterQuery] for enums, Guids and
string[] - or, in a loader, straight off
ctx.To.GetQuery("page"). Give the route a StaleTime and each
distinct query is cached separately, so paging back and forth is instant.
A localized URL prefix
Wrap the app in one parameterized parent route. Its guard sets the culture before any child renders, its template segment binds for every descendant, and the children stay prefix-free - so nothing below has to know the URL carries a language.
<Broute Path="/{lang:length(2)}" Guard="@ApplyCulture"> <Content><BrouterOutlet /></Content> <Routes> <Broute Path="" Component="@typeof(HomePage)" /> @* /en *@ <Broute Path="/products" Component="@typeof(ProductsPage)" /> @* /en/products *@ </Routes> </Broute> private ValueTask ApplyCulture(BrouterNavigationContext ctx) { var lang = ctx.Parameters.GetOrDefault<string>("lang", "en")!; CultureInfo.CurrentUICulture = new CultureInfo(lang); return ValueTask.CompletedTask; }
Inside that subtree, relative links keep working without repeating the prefix:
<BrouterLink Href="./products"> resolves against the current path, so it
lands on /de/products for a German visitor and /en/products for an
English one.
Layout & chrome
A list that remembers where you were
The master-detail annoyance: open an item, press Back, and the list has forgotten your filters and scroll position. Keep-alive fixes it by keeping the list component mounted instead of rebuilding it - the exact pattern this site's docs sidebar uses by staying mounted while its outlet swaps pages.
<Broute Path="/orders" KeepAlive="true" Component="@typeof(OrdersPage)" /> @* Per-parameter retention: /orders/1 and /orders/2 each resume their own state, LRU-evicted beyond the budget. *@ <Broute Path="/orders/{id:int}" KeepAlive="true" KeepAliveMax="3" Component="@typeof(OrderPage)" />
A retained component is still alive - timers keep ticking, subscriptions keep
firing. Pause them: OnDeactivated arrives with
Reason = Hidden instead of Disposing, and OnActivated
is the cue to resume. Add RestoreScrollPosition = true so the window position
comes back with the component, and call brouter.ClearKeepAlive() on sign-out - or
ClearKeepAlive(includeActive: true) when the page on screen is holding the same
no-longer-valid state, as in the switch-tenant recipe above.
Breadcrumbs, page titles and analytics
Attach a label to each route with Meta - static data, no code runs - and read
it from the cascaded BrouterRouteMeta wherever the breadcrumb lives. For
cross-cutting concerns like titles and telemetry, subscribe once to
OnNavigated rather than touching every page.
<Broute Path="/orders" Meta="@(new PageInfo("Orders", Icon.List))" Component="@typeof(OrdersPage)" />
// Any component under the router - a breadcrumb, a header, a title bar: [CascadingParameter] public BrouterRouteMeta Meta { get; set; } = BrouterRouteMeta.Empty; var label = Meta.GetOrDefault<PageInfo>()?.Title ?? "Untitled"; // One subscription, every page - see the API reference for the disposal pattern. brouter.OnNavigated += ctx => { analytics.TrackPageView(ctx.To.Path, ctx.NavigationType); return ValueTask.CompletedTask; };
Need the framework's own RouteData instead - the page type and its route values -
take a plain [CascadingParameter] RouteData?. It's published on every committed
navigation, and is null on not-found.
Shared layout without a URL segment
Two sections of an app often need different chrome without different URLs. A parent route with an empty-path index child gives you a layout that stays mounted while its children swap - and because the parent's content only re-renders when it has to, sidebar scroll and expanded state survive every child navigation.
<Broute Path="/admin"> <Content> <AdminSidebar /> @* stays mounted across child navigations *@ <BrouterOutlet /> </Content> <Routes> <Broute Path="" Component="@typeof(AdminHomePage)" /> @* /admin exactly *@ <Broute Path="users" Component="@typeof(UsersPage)" /> @* /admin/users *@ </Routes> </Broute>
If the shared thing is behavior rather than markup - one guard, one loader, one error
boundary - use a <Broute Group> instead and skip the URL segment
entirely.
Robustness
Handle a failed load without losing the app
A loader that throws shouldn't blank the screen. Put an ErrorContent on the
route (or on an ancestor, to cover a whole section) and the failure renders inside your
layout, with a retry that re-runs the pipeline in place.
<Broute Path="/orders/{id:int}" Loader="@LoadOrder"> <Content><OrderPage /></Content> <ErrorContent Context="err"> <p>Couldn't load this order: @err.Exception.Message</p> <button @onclick="@(() => err.RetryAsync())">Try again</button> </ErrorContent> </Broute>
Boundaries are for display; logging belongs on IBrouter.OnError, which fires
for the same failures in one place. A route that legitimately has no data - the id doesn't
exist - should call NavigationManager.NotFound() instead of throwing, which
hands control to the router's not-found path with the URL left intact.
React to how a navigation ended
When code needs to know whether the user actually got where it sent them - a wizard that
should only clear its draft after leaving succeeds - await the navigation and read the
outcome. NavigationManager can't tell you this; a guard's cancel is invisible
to it.
var outcome = await brouter.NavigateAsync("/checkout/confirm"); if (outcome.Succeeded) _draft.Clear(); else if (outcome.Status is BrouterNavigationStatus.Redirected) logger.LogInformation("Sent to {Url} instead", outcome.RedirectedTo); else if (outcome.Status is BrouterNavigationStatus.Cancelled) ShowToast("Finish the form before continuing.");