All Posts
blazor web

Blazor Navigation System Explained

2025-05-10 · 8 min read


Blazor Navigation System Explained

Blazor’s navigation system trips up almost every developer who comes to it from React, Angular, or Vue. The mental model is different, and if you don’t understand it, you end up with components that don’t re-render when the URL changes, or dispose at the wrong time, or hold onto stale state indefinitely.

This post explains how navigation actually works in Blazor — not at a high level, but at the level where the confusing bugs live.

How Blazor Routing Works

Blazor uses a client-side router (Router component) that intercepts navigation and maps URLs to components using @page directives. When a navigation occurs, Blazor checks if the new route maps to the same component type as the current one.

This matters a lot.

If you navigate from /orders/1 to /orders/2, and both map to OrderDetail.razor, Blazor does not destroy and recreate the component. It reuses the existing instance and updates the parameters. This is efficient, but it means OnInitializedAsync only fires once — when the component is first created.

@page "/orders/{Id:int}"

@code {
    [Parameter] public int Id { get; set; }

    protected override async Task OnParametersSetAsync()
    {
        // This fires on EVERY navigation, including same-component re-navigation
        await LoadOrder(Id);
    }

    protected override async Task OnInitializedAsync()
    {
        // This fires ONCE when the component is first mounted
        // Do NOT rely on this for parameter-dependent data loading
    }
}

The rule: load data in OnParametersSetAsync, not OnInitializedAsync, if the data depends on route parameters.

NavigationManager

NavigationManager is how you programmatically navigate or read the current URL.

@inject NavigationManager Nav

@code {
    void GoToOrders() => Nav.NavigateTo("/orders");

    string GetCurrentPage()
    {
        var uri = new Uri(Nav.Uri);
        return uri.AbsolutePath; // e.g. "/orders/42"
    }
}

Nav.Uri is the full current URL. Nav.BaseUri is your app’s base path (useful when deployed under a sub-path).

Listening for Navigation Events

protected override void OnInitialized()
{
    Nav.LocationChanged += OnLocationChanged;
}

private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
    // e.Location is the new full URL
    InvokeAsync(StateHasChanged); // must marshal back to UI thread
}

public void Dispose()
{
    Nav.LocationChanged -= OnLocationChanged;
}

Always unsubscribe in Dispose. LocationChanged holds a reference to your component. If you forget, you’ve got a memory leak and ghost callbacks firing on disposed components.

The Component Lifecycle and Navigation

Here’s the full sequence when you navigate to a new page:

  1. Router matches the URL to a component
  2. If same component type: parameters are updated → SetParametersAsyncOnParametersSet[Async]
  3. If different component type: old component disposes (IDisposable.Dispose) → new component is created → OnInitialized[Async]OnParametersSet[Async]

The pain point is step 2. Same component, different parameters, no re-initialization.

Query Strings

Blazor doesn’t have built-in query string binding (it was added in .NET 8 for Blazor Web App), but the [SupplyParameterFromQuery] attribute covers it now:

@page "/orders"

@code {
    [SupplyParameterFromQuery]
    public string? Status { get; set; }

    [SupplyParameterFromQuery(Name = "page")]
    public int Page { get; set; } = 1;
}

For earlier versions, you parse manually:

var query = new Uri(Nav.Uri).Query;
var parsed = System.Web.HttpUtility.ParseQueryString(query);
var status = parsed["status"];

Preventing Navigation (Navigation Guards)

Blazor 8+ has NavigationLock for blocking unsaved-changes scenarios:

<NavigationLock OnBeforeInternalNavigation="ConfirmNavigation" ConfirmExternalNavigation="true" />

@code {
    bool _hasUnsavedChanges = false;

    async Task ConfirmNavigation(LocationChangingContext ctx)
    {
        if (_hasUnsavedChanges)
        {
            var confirmed = await JS.InvokeAsync<bool>("confirm", "Discard changes?");
            if (!confirmed) ctx.PreventNavigation();
        }
    }
}

ConfirmExternalNavigation="true" triggers the browser’s native leave-page dialog for external navigations (link clicks, tab close).

Common Bugs and Fixes

Bug: Data doesn’t refresh when navigating between detail pages.
Fix: Move data loading to OnParametersSetAsync. Add a guard to avoid refetching the same ID unnecessarily:

private int _loadedId;

protected override async Task OnParametersSetAsync()
{
    if (Id == _loadedId) return;
    _loadedId = Id;
    await LoadOrder(Id);
}

Bug: Memory leak from LocationChanged subscription.
Fix: Implement IDisposable and unsubscribe. Always. No exceptions.

Bug: Nav.NavigateTo inside OnInitializedAsync causes an infinite loop.
Fix: Use forceLoad: false and check state before navigating. Or navigate from a user event, not a lifecycle method.

Wrapping Up

Blazor’s navigation isn’t complicated, but it works differently from most SPAs. The key insight: same component type means parameter update, not re-creation. Once that’s in your head, the lifecycle methods make sense, the bugs become obvious, and the solutions are straightforward.