Previously, in Part 7, we stopped hard-coding patients and built a real form with validation on Patients.razor. Today we answer a question you've probably been quietly asking since Part 1: how does a URL become a page?

Every screen deserves an address

Think about how you find anything on the web. You don't ask a site to "please show the patients screen" — you go to an address. In Blazor, giving a screen an address takes exactly one line: the @page directive. Our Patients.razor has answered at /patients since Part 5 for precisely this reason:

@page "/patients"

This process of matching a URL to a component is called routing, and the component doing the matching is Blazor's router. When a request for /patients arrives, the router runs through every @page address it knows about, finds the match, and renders that component. Simple — until you need one page to show different content depending on the URL.

Route parameters: one page, many patients

Here's the puzzle. We want /patients/1 to show the first patient and /patients/2 to show the second. Do we write a page per patient? Obviously not — we write one page with a blank to fill in. That blank is a route parameter. Create a new file called PatientDetails.razor:

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

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

Two things are happening here. In the address, {Id:int} marks a segment that can vary — whatever appears after /patients/ gets captured into it. And in the code, a public property named Id wears the [Parameter] attribute, which is Blazor's signal that the captured value should be delivered there. Visit /patients/2 and Id is 2 before the page even renders.

What about the :int part? That's a route constraint — a bouncer at the door. It tells the router this segment must be a whole number, so /patients/2 matches but /patients/abc doesn't even get in. Letters simply won't match, and the router moves on as if this page didn't exist. Here's the whole journey at a glance:

The URL /patients/42 Router finds a matching page PatientDetails renders with Id = 42
Routing in one breath: the router reads the URL, matches a page's route template, and hands over the parameters.

Finding the patient behind the Id

An Id on its own isn't much of a details page. Let's look the patient up and show something human:

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

@if (patient is null)
{
    <p>No patient found with id @Id.</p>
}
else
{
    <h2>@patient.Name</h2>
    <p>Age: @patient.Age</p>
}

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

    private List<Patient> patients = new()
    {
        new Patient { Id = 1, Name = "Asha Rao", Age = 34 },
        new Patient { Id = 2, Name = "Vikram Shah", Age = 52 },
    };

    private Patient? patient => patients.FirstOrDefault(p => p.Id == Id);
}

FirstOrDefault returns the matching patient, or null if the Id doesn't exist — which is why the markup checks for null before celebrating.

Note: Yes, this page has its own little patient list, separate from the one on Patients.razor. That's a temporary crutch, and it will start to hurt the moment you add a patient on one page and the other doesn't notice. Sharing one list properly between pages is exactly what Part 9 is about.

Linking between pages

How do users actually get to these addresses? The humble anchor tag works everywhere: <a href="/patients/2"> takes you straight to Vikram. For navigation menus, Blazor offers NavLink, which behaves like an anchor but adds an active CSS class when its address matches the current URL — that's how menus highlight where you are:

<NavLink href="/patients">Patients</NavLink>

Navigating from code

Sometimes navigation isn't a link — it's a decision your code makes. Remember OnSelected, the EventCallback<Patient> we gave PatientCard.razor in Part 6? Let's make clicking a card jump to that patient's details. Blazor provides NavigationManager for exactly this; ask for it with @inject at the top of Patients.razor:

@inject NavigationManager Nav

<PatientCard Patient="p" OnSelected="GoToDetails" />

@code {
    private void GoToDetails(Patient p)
    {
        Nav.NavigateTo($"/patients/{p.Id}");
    }
}

Click a card, the callback fires, and NavigateTo builds the URL from the patient's Id and sails you there. Cards, callbacks, and routes — three parts of the series clicking together.

When no route matches

What if someone types /bananas? The router checks every address it knows, finds nothing, and falls back to its not-found content — the "Sorry, there's nothing at this address" screen you may have already stumbled into. Current Blazor (.NET 10) handles this nicely out of the box: NavigationManager now has built-in NotFound() handling, so showing a proper 404 page works cleanly whether your page is statically rendered or fully interactive. And note the difference from our null check earlier — /patients/999 does match a route; it's our own page that discovers the patient doesn't exist.

By the way, routing is only the last leg of a URL's journey — DNS lookups, requests, and responses all happen first. For the bigger picture, see what happens when you type a URL.

Up next

ClinicApp now has two proper pages — but as that note above warned, they each hoard their own copy of the patient list, and copies drift apart. The fix is one shared PatientService that every page borrows from, courtesy of something called dependency injection. It sounds scarier than it is, and it's all in Part 9.