Routing and Layouts
By the end of this lesson
Define routes and share surrounding layout.
Routes in Blazor come from the components themselves. A component with a @page directive claims an address, and the router builds its table by scanning the assembly at startup. There is no route file to keep in step with the pages.
A layout is the other half of the same subject. It is a component with a hole in it: the shared header, navigation and footer, with the routed page rendered in the middle. Every page gets one without asking, and a page can opt out when it needs to.
@page "/employees/{Id:int}"
@page "/employees/{Id:int}/{Tab}"
@rendermode InteractiveServer
@inject EmployeeService Employees
@inject NavigationManager Navigation
<h1>@(employee?.Name ?? "Employee")</h1>
@if (employee is null)
{
<p role="status">No employee has id @Id.</p>
}
else
{
<p>@employee.Department, started @employee.StartDate.ToString("d MMM yyyy")</p>
<p>Section: @(Tab ?? "details")</p>
<button type="button" @onclick="BackToList">Back to the list</button>
}
@code {
[Parameter]
public int Id { get; set; }
[Parameter]
public string? Tab { get; set; }
private Employee? employee;
protected override void OnParametersSet()
{
employee = Employees.Find(Id);
}
private void BackToList() => Navigation.NavigateTo("/employees");
}- @page is the route, and it must begin with a slash. Request /employees/42 and the router renders this component.
- Two @page directives on one component are allowed. Both addresses render this page; the second supplies a Tab value and the first leaves it null.
- {Id:int} is a route parameter with a constraint. /employees/abc does not match this route at all, so you never parse a segment that was never a number.
- A route parameter is matched to a [Parameter] property by name, ignoring case. Spell them differently and the property keeps its default value, which shows up as a page about employee zero.
- @inject asks for a service registered in Program.cs. Dependency injection has its own lesson later; NavigationManager is the exception worth knowing now, because the framework provides it without any registration of yours.
- OnParametersSet runs whenever the parameters have been set, including when only the route changed. Loading in OnInitialized instead would miss a move from /employees/42 to /employees/43, because the component is reused and never initialises again.
- NavigateTo changes the address and renders the matching component, without a full page load.
The routing features you will reach for, with the behaviour that surprises people:
- A literal route
- @page "/employees" claims one address. Matching ignores case, and the leading slash is required.
- Route parameter
- @page "/employees/{Id}" captures a segment into a [Parameter] property of the same name. Without a constraint it arrives as a string.
- Constraint
- {Id:int} restricts the segment to a type — int, long, guid, bool, decimal, datetime and a few more. A value that does not fit means the route does not match, which produces a 404 rather than an error inside your component.
- Optional parameter
- {Tab?} matches with or without that segment, and the property has to be nullable. Two @page directives do the same job with more visible intent.
- Catch-all parameter
- {*rest} captures everything left in the path, slashes included. Useful for a file-tree style address, and worth using sparingly because it swallows mistakes.
- Query string
- Read with [SupplyParameterFromQuery] on a property. Query values are not route parameters and never appear in a @page directive.
- NavLink
- A link component that adds a CSS class when its href matches the current address, which is how navigation shows where you are.
@inject NavigationManager Navigation
@code {
[SupplyParameterFromQuery]
public string? Department { get; set; }
[SupplyParameterFromQuery(Name = "page")]
public int? PageNumber { get; set; }
private async Task Save(EmployeeEdit edit)
{
var saved = await Employees.SaveAsync(edit);
Navigation.NavigateTo($"/employees/{saved.Id}");
}
private void FilterBy(string department)
{
Navigation.NavigateTo(
Navigation.GetUriWithQueryParameter("department", department));
}
}- NavigationManager reports the current address and changes it. Injecting it needs no registration from you.
- NavigateTo with a relative path performs a client-side navigation: the router matches the new address and renders the page, with no full reload. Pass forceLoad when you genuinely need the server to serve the page again, and leave a comment saying why, because it is rare.
- SupplyParameterFromQuery reads a query string value into a property, and Name lets the property and the parameter differ. Filters, paging and sort order belong in the query string, because then the current view is an address someone can share and the back button behaves the way people expect.
- GetUriWithQueryParameter rebuilds the current address with one parameter changed, keeping the others. Concatenating query strings by hand is where duplicated parameters and lost filters come from.
- Navigating after a save is a choice, not a rule. Staying put with a clear confirmation is often kinder; if you do navigate, make sure the destination shows evidence that the save worked, rather than leaving the reader to assume.
@inherits LayoutComponentBase
<div class="portal">
<header>
<a class="skip-link" href="#main">Skip to main content</a>
<span class="brand">Employee Portal</span>
</header>
<nav aria-label="Main">
<NavLink href="/employees" Match="NavLinkMatch.Prefix">Employees</NavLink>
<NavLink href="/reports" Match="NavLinkMatch.All">Reports</NavLink>
</nav>
<main id="main">
@Body
</main>
<footer>Internal use only. Contact the IT service desk for access.</footer>
</div>
@* A page that should not show the navigation chrome *@
@page "/reports/print"
@layout PrintLayout- @inherits LayoutComponentBase is what makes this component a layout. It supplies the Body property.
- @Body is the hole. The routed page renders there, and everything around it is shared by every page using this layout.
- The default layout is named once, on the RouteView in Routes.razor, so no page has to opt in.
- @layout on a page overrides that default. A print view, or a sign-in screen that should not offer navigation to people who cannot use it yet, are the usual reasons.
- Layouts nest: a layout can itself declare @layout, so a section of the portal can add a sub-navigation inside the main surroundings.
- Match="NavLinkMatch.Prefix" keeps the Employees link marked as current while you are on /employees/42. NavLinkMatch.All marks it only on an exact match, which is what a home link wants.
- The nav element has an accessible name, and the skip link lets a keyboard user pass navigation that repeats on every page. If your design signals the current page with colour, add aria-current to the active link as well — a CSS class carries no meaning to a screen reader.
Decisions worth making deliberately rather than by habit:
- Put state that identifies what is on screen in the address: which employee, which filter, which page of results. Then a link can reproduce the view and the back button works.
- Keep state that is nobody else's business out of the address: whether a panel is expanded, the half-typed contents of a box. Every navigation adds a history entry, and a history full of expand-and-collapse makes the back button useless.
- Load data in OnParametersSetAsync on any page with a route parameter, because the component is reused when that parameter changes.
- Give the layout only what every page shares. A layout that knows about employees is a layout that has to change when the employee screens do.
- Design the address for an unmatched route before you need it. In a Blazor Web App the server answers with a 404; a standalone WebAssembly application needs a NotFound section in its router.
Summary
- A route is declared on the component with @page, and the router builds its table by scanning for those directives
- Route parameters bind to [Parameter] properties by name, and constraints such as {Id:int} mean a bad value does not match rather than failing inside your code
- Pages with route parameters must load data in OnParametersSet, because the component is reused when the parameter changes
- NavigationManager changes the address in code, and query string values read with [SupplyParameterFromQuery] keep a view shareable
- A layout inherits LayoutComponentBase and renders the page at @Body; the router sets the default and @layout overrides it per page
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Make the filtered list shareable
Change the employee list so the department filter and the page number live in the query string instead of in private fields.
Then copy the address, paste it into a new tab, and use the back button. What behaviour did you get for free?
Show solution
The view became reproducible. A colleague can be sent the exact list you are looking at, and the back button steps through filters because each navigation is a real history entry.
You also removed a category of bug. With filters in private fields, a page refresh or a return from the detail screen silently resets them, and the reader assumes their filter was applied when it was not.
The cost is that every filter change adds to history, so a filter someone adjusts constantly is better kept in a field. That is the judgement to make: address for what identifies the view, field for what is incidental to it.
@page "/employees"
@code {
[SupplyParameterFromQuery]
public string? Department { get; set; }
[SupplyParameterFromQuery(Name = "page")]
public int? PageNumber { get; set; }
private List<Employee> visible = [];
protected override async Task OnParametersSetAsync()
{
visible = await Employees.SearchAsync(Department, PageNumber ?? 1);
}
private void ShowDepartment(string department) =>
Navigation.NavigateTo(
Navigation.GetUriWithQueryParameter("department", department));
}Think about it
Route or state?
The employee detail screen is getting tabs: details, absence and equipment.
Should the selected tab be a route segment, as in /employees/42/absence, or a field on the component? Argue both sides and then commit to one.
Show solution
A route segment makes each tab linkable. A manager can send a colleague straight to an absence record, a refresh returns to the same tab, and the back button moves between tabs. The cost is that every tab click becomes a history entry and the page reloads its data through OnParametersSetAsync.
A field is simpler and instant. Nothing navigates, no data reloads unless you ask, and history stays clean. The cost is that the tab is invisible from outside: no link, and a refresh drops the reader back on the first tab.
The deciding question is whether anyone would ever want to link to a tab. For absence and equipment records in an HR portal, almost certainly yes, so the route wins. For a details-and-raw-JSON toggle that only developers use, the field wins.
Both answers are defensible, which is the point of the exercise. What is not defensible is choosing without asking the question, and then discovering the requirement to share a link after the screen is built.
Saved in this browser only.