Frontend Engineering

Exploring Web Development with Blazor: Practical .NET Notes and Examples

Afzal AhmedFaz Ahmed
·27 July 2026·20 min read
BlazorC#.NETRazor ComponentsBlazor WebAssemblyInteractive Server.NET AspirebUnitASP.NET Core

Why This Matters

My practical exploration of Blazor render modes, Aspire, components, forms, state, APIs, authentication, JavaScript interop, testing and deployment.

Let’s treat this as a serious one-to-one mentoring session about modern Blazor development.

If you already know C# and ASP.NET Core, it is tempting to think Blazor is simply a matter of learning Razor components. That is only the surface. To build a dependable application, I want you to understand where the code runs, how each render mode changes the architecture, how components communicate, how state survives navigation, where APIs become necessary, when JavaScript still has a role and how the application will be tested and operated.

We will work from the fundamentals into render modes, Aspire, components, forms, state management, APIs, authentication, JavaScript interop, testing, deployment, WebAssembly and .NET MAUI Blazor Hybrid. My aim is to connect each feature to the engineering decision behind it.

That is our target: not merely knowing Blazor syntax, but understanding how to design a production-quality Blazor application.


1. What Blazor really is

Blazor is a web UI framework that lets us build interactive web applications using C#, Razor, HTML and CSS.

The big mental shift is this:

In React, Angular or Vue, the interactive frontend logic usually lives in JavaScript or TypeScript.

In Blazor, much of that same frontend logic can live in C#.

So instead of writing:

export interface LoanApplication {
  id: string;
  applicantName: string;
  requestedAmount: number;
  status: string;
}

and then also writing a similar C# DTO on the backend, Blazor allows us to share C# models between client and server when the architecture allows it.

For example:

public sealed record LoanApplicationDto(
    Guid Id,
    string ApplicantName,
    decimal RequestedAmount,
    string Status);

That one C# type can be used by your ASP.NET Core API and your Blazor UI project if placed in a shared/client project.

That is one of Blazor’s biggest strengths for a .NET developer: fewer mental context switches.

But be careful. Blazor does not mean “JavaScript is dead.” It means JavaScript is no longer the default language for most UI logic in your .NET web application. You still use HTML. You still use CSS. You still need browser knowledge. And sometimes, you still use JavaScript interop.

So the honest definition is:

Blazor is a .NET web UI framework that lets you build component-based web applications using Razor and C#, and it can run using different render modes: server rendering, interactive server, WebAssembly, Auto, and hybrid/native hosting.

That last sentence is important. Blazor is not just WebAssembly. WebAssembly is only one way to run Blazor, and modern Blazor gives us several rendering choices.


2. The project structure: what starts the app?

When you create a modern Blazor Web App, you normally see a server project and sometimes a client project.

A typical solution might look like this:

LoanPortal.AppHost
LoanPortal.ServiceDefaults
LoanPortal
LoanPortal.Client

The AppHost and ServiceDefaults projects come from Aspire. The main Blazor server project hosts the app. The client project contains WebAssembly-capable components and shared models.

A simplified Program.cs might look like this:

var builder = WebApplication.CreateBuilder(args);

// Aspire shared defaults: logging, tracing, health checks, service discovery.
builder.AddServiceDefaults();

// Add Razor components and enable interactive modes.
builder.Services
    .AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseAntiforgery();

app.MapStaticAssets();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode()
    .AddInteractiveWebAssemblyRenderMode()
    .AddAdditionalAssemblies(typeof(LoanPortal.Client._Imports).Assembly);

app.Run();

Now let’s explain this like a mentor.

AddRazorComponents() says: “This app can render Razor components.”

AddInteractiveServerComponents() says: “Some components can run interactively on the server using SignalR.”

AddInteractiveWebAssemblyComponents() says: “Some components can run interactively in the browser using WebAssembly.”

MapRazorComponents() tells ASP.NET Core where the component tree starts.

AddAdditionalAssemblies(...) tells the server project that there are components in the client assembly as well.

Now look at App.razor. This is the shell of the app:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <base href="/" />

    <link rel="stylesheet" href="@Assets["app.css"]" />

    <HeadOutlet @rendermode="InteractiveAuto" />
</head>
<body>
    <Routes @rendermode="InteractiveAuto" />

    <script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>

Routes handles routing. HeadOutlet allows pages/components to set title and metadata. The Blazor script enables the runtime behaviour needed for navigation, interactivity and rendering.

I want you to see more than markup here: this is the bootstrapping point of the Blazor application.


3. Render modes: the most important modern Blazor topic

If you want to understand modern Blazor properly, understand render modes deeply: static SSR, streaming SSR, Interactive Server, Interactive WebAssembly, Auto mode and where to place interactivity.

Let’s simplify.

Static SSR

Static Server-Side Rendering means the server renders HTML and sends it to the browser.

No Blazor interactivity runs after that.

@page "/loans"

<h1>Loan Applications</h1>

<p>This page can be rendered as normal HTML from the server.</p>

This is excellent for:

SEO. Fast first page load. Public content. Read-only pages. Marketing pages. Documentation pages.

But if you add a button like this:

<button @onclick="Approve">Approve</button>

@code {
    private void Approve()
    {
        // This will not work in pure static SSR.
    }
}

it will not behave interactively unless the component has an interactive render mode.

Streaming SSR

Static SSR waits until the full page is ready before sending HTML.

Streaming SSR sends the first part quickly, then sends slower parts later.

@page "/dashboard"
@attribute [StreamRendering]

<h1>Loan Dashboard</h1>

@if (_summary is null)
{
    <p>Loading dashboard summary...</p>
}
else
{
    <p>Total applications: @_summary.TotalApplications</p>
}

@code {
    private DashboardSummary? _summary;

    protected override async Task OnInitializedAsync()
    {
        await Task.Delay(2000); // Simulating slow API/database call

        _summary = new DashboardSummary(128, 12);
    }

    private sealed record DashboardSummary(int TotalApplications, int PendingApprovals);
}

This improves perceived performance. The user sees something quickly instead of a blank screen.

Interactive Server

Interactive Server means the component runs on the server. The browser keeps a SignalR connection open. User events go to the server. The server processes them and sends DOM updates back.

@page "/counter"
@rendermode InteractiveServer

<h1>Counter</h1>

<p>Current count: @_count</p>

<button class="btn btn-primary" @onclick="Increment">
    Click me
</button>

@code {
    private int _count;

    private void Increment()
    {
        _count++;
    }
}

Benefits:

Small client download. C# stays on the server. Can access server services directly. Good for internal business apps.

Downsides:

Needs constant connection. Every click is a round trip. Server holds circuit state. Scaling needs planning. Offline support is weak.

For an internal admin portal with 200 known users, Interactive Server can be excellent.

For a public high-traffic app with unpredictable usage, think carefully.

Interactive WebAssembly

Interactive WebAssembly runs .NET in the browser.

@page "/loan-calculator"
@rendermode InteractiveWebAssembly

<h1>Loan Calculator</h1>

<input type="number" @bind="_amount" />
<input type="number" @bind="_rate" />

<p>Estimated yearly interest: @_amount * (_rate / 100)</p>

@code {
    private decimal _amount = 250000;
    private decimal _rate = 5.5m;
}

Benefits:

Runs in browser. No round trip for every interaction. Can support offline/PWA scenarios. Can be hosted as static files.

Downsides:

Larger initial download. Code is visible/decompilable. Needs APIs for database/server work. Startup can be heavier than server rendering.

Interactive Auto

Auto tries to combine both worlds.

First visit: use Interactive Server quickly while WebAssembly downloads in the background.

Later visit: use cached WebAssembly.

This is powerful because users get fast interactivity now and browser-based execution later.

My rule of thumb:

Use SSR for public/read-only content. Use Streaming SSR for slow-loading server-rendered pages. Use Interactive Server for internal apps or server-heavy workflows. Use WebAssembly for offline, static hosting, and browser-heavy UI. Use Auto when you want a balanced modern default.


4. Aspire: the local development upgrade

Aspire is not “just another project template.”

Aspire helps define and run everything your application needs: frontend, API, database, cache, message queue, services, logs, traces and metrics. AppHost wires projects and dependencies together, while ServiceDefaults centralises logging, tracing, health checks and related defaults.

Imagine a new developer joins your team.

Without Aspire:

“Start PostgreSQL. Then Redis. Then API. Then UI. Check port 5001. No, mine is 7201. Copy this connection string. Run this script. Ask Imran for secrets.”

With Aspire:

“Clone repo. Press F5.”

That matters.

Example AppHost:

var builder = DistributedApplication.CreateBuilder(args);

var postgres = builder.AddPostgres("postgres")
    .WithDataVolume()
    .WithPgAdmin();

var database = postgres.AddDatabase("loandb");

builder.AddProject<Projects.LoanPortal>("loanportal")
    .WithReference(database)
    .WaitFor(database);

builder.Build().Run();

This says:

Create PostgreSQL. Create a database. Run the Blazor app. Pass connection details automatically. Wait until the database is ready.

The Aspire dashboard then gives you services, logs, traces, metrics and health status. That is not only convenient. It teaches developers to think operationally.

A professional Blazor developer does not only ask, “Does my component render?”

They ask:

Can I run the full system locally? Can I see logs? Can I trace a request? Can I see slow API calls? Can a new developer start quickly?

That is the Aspire mindset.


5. Data, DTOs and repository thinking

The same architectural lesson applies whether we are building a blog, loan portal or enterprise administration system: database entities, DTOs, repositories, EF Core and API/client boundaries need distinct responsibilities.

Let’s apply the same idea to a loan portal.

Database entity:

public class LoanApplicationEntity
{
    public int Id { get; set; }

    public string ApplicantName { get; set; } = string.Empty;

    public decimal RequestedAmount { get; set; }

    public string Status { get; set; } = "Submitted";

    public DateTime SubmittedAt { get; set; }
}

DTO:

public sealed class LoanApplicationDto
{
    public string? Id { get; set; }

    public string ApplicantName { get; set; } = string.Empty;

    public decimal RequestedAmount { get; set; }

    public string Status { get; set; } = string.Empty;

    public DateTime SubmittedAt { get; set; }
}

Why separate entity and DTO?

Because the database shape is not always the API/UI shape.

The entity may contain audit columns, internal flags, deleted markers, row versions, internal workflow IDs and database relationships.

The DTO should contain what the UI needs.

Repository interface:

public interface ILoanApplicationRepository
{
    Task<int> GetCountAsync();

    Task<IReadOnlyList<LoanApplicationDto>> GetApplicationsAsync(
        int skip,
        int take);

    Task<LoanApplicationDto?> GetByIdAsync(string id);

    Task<LoanApplicationDto> SaveAsync(LoanApplicationDto application);

    Task DeleteAsync(string id);
}

Direct EF implementation:

public sealed class EfLoanApplicationRepository(
    IDbContextFactory<LoanDbContext> factory)
    : ILoanApplicationRepository
{
    public async Task<IReadOnlyList<LoanApplicationDto>> GetApplicationsAsync(
        int skip,
        int take)
    {
        await using var context = await factory.CreateDbContextAsync();

        return await context.LoanApplications
            .AsNoTracking()
            .OrderByDescending(x => x.SubmittedAt)
            .Skip(skip)
            .Take(take)
            .Select(x => new LoanApplicationDto
            {
                Id = x.Id.ToString(),
                ApplicantName = x.ApplicantName,
                RequestedAmount = x.RequestedAmount,
                Status = x.Status,
                SubmittedAt = x.SubmittedAt
            })
            .ToListAsync();
    }

    public async Task<int> GetCountAsync()
    {
        await using var context = await factory.CreateDbContextAsync();

        return await context.LoanApplications.CountAsync();
    }

    public async Task<LoanApplicationDto?> GetByIdAsync(string id)
    {
        if (!int.TryParse(id, out var numericId))
            return null;

        await using var context = await factory.CreateDbContextAsync();

        return await context.LoanApplications
            .AsNoTracking()
            .Where(x => x.Id == numericId)
            .Select(x => new LoanApplicationDto
            {
                Id = x.Id.ToString(),
                ApplicantName = x.ApplicantName,
                RequestedAmount = x.RequestedAmount,
                Status = x.Status,
                SubmittedAt = x.SubmittedAt
            })
            .SingleOrDefaultAsync();
    }
}

The architectural point I emphasise:

In Blazor Server, components may call server services directly.

In Blazor WebAssembly, components cannot directly access the database. They must call an API.

So your architecture must respect where the component runs.

That is why shared interfaces and separate implementations matter.


6. Components: the heart of Blazor

A Blazor component is a reusable UI block written in Razor.

Example:

@page "/applications"

<h1>Loan Applications</h1>

@if (_applications is null)
{
    <p>Loading...</p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Applicant</th>
                <th>Amount</th>
                <th>Status</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var app in _applications)
            {
                <tr>
                    <td>@app.ApplicantName</td>
                    <td>@app.RequestedAmount.ToString("C")</td>
                    <td>@app.Status</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    [Inject]
    public required ILoanApplicationRepository Repository { get; set; }

    private IReadOnlyList<LoanApplicationDto>? _applications;

    protected override async Task OnInitializedAsync()
    {
        _applications = await Repository.GetApplicationsAsync(0, 20);
    }
}

This component has markup and code. The markup describes the UI. The C# code loads data and controls state.

Important lifecycle methods:

OnInitializedAsync() runs when the component is initialised. Use it for initial loading.

OnParametersSetAsync() runs when component parameters are assigned or changed.

OnAfterRenderAsync() runs after rendering. Use it for DOM-dependent work or JS interop.

ShouldRender() lets you control whether the component should rerender.

Example with parameters:

<LoanStatusBadge Status="Approved" />

Component:

<span class="@CssClass">@Status</span>

@code {
    [Parameter]
    public string Status { get; set; } = string.Empty;

    private string CssClass => Status switch
    {
        "Approved" => "badge bg-success",
        "Rejected" => "badge bg-danger",
        "Submitted" => "badge bg-secondary",
        _ => "badge bg-light text-dark"
    };
}

Parameters allow parent components to pass data into child components.

My rule of thumb:

A component should have a clear responsibility. If one component loads data, handles forms, renders tables, controls modals, validates inputs, manages navigation and talks to JavaScript, it is probably too large.


7. EventCallback and component communication

Blazor uses EventCallback for child-to-parent communication.

Child component:

<button class="btn btn-success" @onclick="Approve">
    Approve
</button>

@code {
    [Parameter]
    public Guid ApplicationId { get; set; }

    [Parameter]
    public EventCallback<Guid> OnApproved { get; set; }

    private async Task Approve()
    {
        await OnApproved.InvokeAsync(ApplicationId);
    }
}

Parent component:

<ApproveButton ApplicationId="app.Id" OnApproved="HandleApproved" />

@code {
    private async Task HandleApproved(Guid id)
    {
        await WorkflowService.ApproveAsync(id);

        _applications = await Repository.GetApplicationsAsync(0, 20);
    }
}

Why not just use normal .NET events?

Because EventCallback is designed for Blazor rendering. It supports async and helps Blazor update the UI after the callback completes. It is the standard pattern I use when a component exposes a callback to its parent.

My rule of thumb:

Use parameters to pass data down. Use EventCallback to notify up. Use state containers or cascading values only when many components need shared state.


8. RenderFragment: reusable UI, not just reusable logic

RenderFragment allows a component to accept UI content from its parent.

Example alert component:

<div class="alert alert-@Type">
    <strong>@Title</strong>

    <div>
        @ChildContent
    </div>
</div>

@code {
    [Parameter]
    public string Type { get; set; } = "info";

    [Parameter]
    public string Title { get; set; } = "Information";

    [Parameter]
    public RenderFragment? ChildContent { get; set; }
}

Usage:

<Alert Type="warning" Title="Credit Risk Warning">
    This applicant has a high debt-to-income ratio.
</Alert>

Now the alert component owns the structure and styling, but the parent supplies the content.

This is how you build flexible component libraries.

RenderFragment represents a fragment of Razor content that can be supplied to a component, with both non-generic and generic forms available.

My rule of thumb:

Use RenderFragment when you want reusable layout with custom content.


9. Forms and validation

Blazor forms are built around EditForm, model binding and validation.

<EditForm Model="_model" OnValidSubmit="SaveAsync">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <div class="mb-3">
        <label>Applicant Name</label>
        <InputText class="form-control" @bind-Value="_model.ApplicantName" />
        <ValidationMessage For="@(() => _model.ApplicantName)" />
    </div>

    <div class="mb-3">
        <label>Requested Amount</label>
        <InputNumber class="form-control" @bind-Value="_model.RequestedAmount" />
        <ValidationMessage For="@(() => _model.RequestedAmount)" />
    </div>

    <button class="btn btn-primary">Save</button>
</EditForm>

@code {
    private CreateLoanApplicationModel _model = new();

    private async Task SaveAsync()
    {
        await Repository.SaveAsync(new LoanApplicationDto
        {
            ApplicantName = _model.ApplicantName,
            RequestedAmount = _model.RequestedAmount,
            Status = "Submitted",
            SubmittedAt = DateTime.UtcNow
        });
    }

    public sealed class CreateLoanApplicationModel
    {
        [Required]
        public string ApplicantName { get; set; } = string.Empty;

        [Range(1000, 1_000_000)]
        public decimal RequestedAmount { get; set; }
    }
}

Key pieces:

EditForm creates the form context. InputText, InputNumber, InputSelect bind values. DataAnnotationsValidator enables validation attributes. ValidationMessage shows field-level errors. ValidationSummary shows all errors.

The security principle I emphasise:

Client-side validation improves user experience. Server-side validation protects the system. Never trust the browser.


10. APIs and authentication

Blazor WebAssembly components need APIs to talk to the server.

Minimal API example:

app.MapGet("/api/loan-applications",
    async (ILoanApplicationRepository repository, int skip = 0, int take = 20) =>
    {
        var items = await repository.GetApplicationsAsync(skip, take);
        return Results.Ok(items);
    });

app.MapPost("/api/loan-applications",
    async (ILoanApplicationRepository repository, LoanApplicationDto dto) =>
    {
        var saved = await repository.SaveAsync(dto);
        return Results.Created($"/api/loan-applications/{saved.Id}", saved);
    });

Client repository:

public sealed class ApiLoanApplicationRepository(HttpClient httpClient)
    : ILoanApplicationRepository
{
    public async Task<IReadOnlyList<LoanApplicationDto>> GetApplicationsAsync(
        int skip,
        int take)
    {
        return await httpClient.GetFromJsonAsync<List<LoanApplicationDto>>(
            $"/api/loan-applications?skip={skip}&take={take}") ?? [];
    }
}

Authentication and authorization protect pages and APIs.

@attribute [Authorize(Roles = "Admin")]

<h1>Admin Area</h1>

In Blazor, always understand the difference:

Authentication: who is the user? Authorization: what is the user allowed to do?

For WebAssembly, remember that UI protection is not enough. APIs must enforce authorization too.


11. JavaScript interop: when C# needs the browser

Sometimes you still need JavaScript.

Examples:

Browser APIs. Third-party JS libraries. Charting libraries. Local DOM behaviour. File download helpers. Clipboard access.

Blazor can call JavaScript:

@inject IJSRuntime JS

<button class="btn btn-outline-secondary" @onclick="CopyReference">
    Copy Reference
</button>

@code {
    private async Task CopyReference()
    {
        await JS.InvokeVoidAsync("navigator.clipboard.writeText", "LOAN-2026-001");
    }
}

JavaScript can call .NET too, using [JSInvokable].

My rule of thumb:

Use JavaScript interop when needed, but do not turn your Blazor app into a hidden JavaScript app. Keep interop small, isolated and testable.


12. State management

State means the information the UI needs to remember.

Local state:

private bool _showAdvancedFilters;

URL state:

@page "/applications/{Status}"

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

Query string state:

@page "/applications"
@attribute [SupplyParameterFromQuery]

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

Browser storage is useful for non-sensitive persistence, such as theme or grid preferences.

State container:

public sealed class LoanApplicationState
{
    public string? CurrentSearch { get; private set; }

    public event Action? OnChange;

    public void SetSearch(string search)
    {
        CurrentSearch = search;
        OnChange?.Invoke();
    }
}

Register:

builder.Services.AddScoped<LoanApplicationState>();

Component:

@implements IDisposable
@inject LoanApplicationState State

<input value="@State.CurrentSearch" @oninput="SearchChanged" />

@code {
    protected override void OnInitialized()
    {
        State.OnChange += StateHasChanged;
    }

    private void SearchChanged(ChangeEventArgs e)
    {
        State.SetSearch(e.Value?.ToString() ?? "");
    }

    public void Dispose()
    {
        State.OnChange -= StateHasChanged;
    }
}

The caution I give developers:

Do not store sensitive tokens or confidential business data casually in browser storage.

Do not create global state for everything.

Use local state first. Lift state when needed. Use a state container when multiple components genuinely need shared state.


13. Debugging, tracing, metrics and testing

A professional Blazor developer must know how to run the app with confidence.

Debugging differs between Blazor Server and WebAssembly. Blazor Server is easier because code runs on the server. WebAssembly debugging involves the browser runtime.

Tracing and metrics matter because modern Blazor apps interact with APIs, databases and services. I use a simple distinction: logs explain events, metrics reveal patterns and traces show the path of a request.

That distinction is useful both in interviews and production investigations.

Testing Blazor components is commonly done with bUnit.

Example:

[Fact]
public void StatusBadge_ShowsApprovedClass()
{
    using var ctx = new TestContext();

    var component = ctx.RenderComponent<LoanStatusBadge>(
        parameters => parameters.Add(p => p.Status, "Approved"));

    component.MarkupMatches(
        "<span class=\"badge bg-success\">Approved</span>");
}

Test behaviour, not just markup:

[Fact]
public void ApproveButton_RaisesCallback()
{
    using var ctx = new TestContext();

    Guid? approvedId = null;
    var id = Guid.NewGuid();

    var component = ctx.RenderComponent<ApproveButton>(parameters => parameters
        .Add(p => p.ApplicationId, id)
        .Add(p => p.OnApproved, EventCallback.Factory.Create<Guid>(
            this,
            value => approvedId = value)));

    component.Find("button").Click();

    Assert.Equal(id, approvedId);
}

My rule of thumb:

A Blazor component test should answer: when this input is given and this user action happens, does the component render or behave correctly?


14. Deployment, WebAssembly performance, source generators and MAUI

Deployment depends on render mode.

Interactive Server needs ASP.NET Core hosting because the server runs the UI logic.

WebAssembly standalone can be hosted as static files.

Interactive Auto and Blazor Web App need server hosting.

WebAssembly performance topics include:

AOT compilation. Trimming. Lazy loading. PWA support. Progress indicators. Native dependencies. Prerendering issues.

AOT can improve runtime performance but increases build output and build time. Trimming removes unused code but can break reflection-heavy scenarios if not configured carefully.

Source generators create C# code at compile time. They can reduce boilerplate and improve performance because work happens during compilation rather than runtime.

.NET MAUI Blazor Hybrid lets you reuse Blazor components in native apps for Android, iOS, macOS and Windows. The router, layouts and components remain familiar, while Blazor Hybrid can access native device capabilities more directly than browser-hosted Blazor.

That is powerful. It means your Blazor component knowledge can travel beyond the browser.


15. A production mentoring exercise: build the loan-review workspace

We have covered the parts. Now I want to mentor you through the decisions that join those parts into a dependable application.

Imagine that a lender asks us to build a workspace where an authorised underwriter can open a loan application, inspect supporting information, add a note and approve or decline the application. The page must load quickly from a link in an email, remain usable on an unreliable connection, prevent two reviewers from silently overwriting each other, and leave an audit trail.

Junior: Shall I start by making a large LoanReview.razor component and inject the database context?
>
Senior: Start by writing the trust boundaries and user journeys. A component tree is easier to change than a security model discovered too late.
The first useful artefact is not code. It is a small authority map:
OperationWho may request it?Where is it enforced?What is recorded?
View summaryAssigned reviewer or supervisorServer query and policyRead telemetry, if required
Add noteAssigned reviewerServer command handlerAuthor, time and text
ApproveReviewer with approval permissionServer policy and domain rulePrevious and new state
DeclineReviewer with decline permissionServer policy and domain ruleReason and state transition
ReassignSupervisorServer policyOld and new assignee
The browser may hide an Approve button, but hiding it is presentation. The server must still reject an unauthorised approval. A disabled button is not an access-control boundary. A user can construct an HTTP request, call an endpoint outside our UI or modify client-side state.

Next, describe the page as capabilities rather than visual boxes:

  • a read-only application summary;
  • a document list;
  • an editable reviewer note;
  • approval and decline commands;
  • a visible version indicator when another reviewer changes the record;
  • recovery of unsaved note text after a transient disconnect.
This list tells us which parts need interactivity and which parts can remain static. That is a more disciplined starting point than marking the whole application interactive because one button needs a click handler.

Choose a render mode deliberately

In a .NET 10 Blazor Web App, the important choices are static server-side rendering, Interactive Server, Interactive WebAssembly and Interactive Auto. Prerendering is enabled by default for interactive components. Streaming rendering is an SSR behaviour that can improve perceived loading when asynchronous content is slow; it is not another place where component code permanently executes.

For this workspace, I would initially choose Interactive Server for the review panel. The users are authenticated staff, the application already requires a server, the first download should be small, and direct access to server-side application services simplifies the first implementation. That choice has consequences: a circuit holds UI state on the server, events travel over a real-time connection, server memory grows with connected users, and reconnect behaviour becomes part of the experience.

I might keep the public loan-product description as static SSR. It needs links and forms but no long-lived client component state. I might choose Interactive WebAssembly for an offline-capable calculator whose code and data are safe to send to the browser. Interactive Auto can provide server interactivity on the first visit and WebAssembly on later visits after the bundle is available, but it does not dynamically move an already-rendered component from server to WebAssembly midway through that visit.

That last detail prevents a common architectural mistake. Auto is not magical live migration. Code intended for Auto must be capable of running under its assigned execution environments, and service access must be designed accordingly.

Render modes also flow down the component hierarchy. You cannot casually give a child a conflicting interactive render mode. When a static parent passes data into an interactive boundary, parameters must be serialisable because the framework has to cross that boundary. A delegate, open database context or arbitrary server object is not a sensible parameter.

Use a transport-shaped model:

public sealed record LoanReviewModel(
    Guid ApplicationId,
    string ApplicantDisplayName,
    decimal RequestedAmount,
    string Status,
    string AssignedReviewer,
    long Version,
    IReadOnlyList<ReviewDocumentModel> Documents);

public sealed record ReviewDocumentModel(
    Guid Id,
    string DisplayName,
    long SizeInBytes,
    DateTimeOffset UploadedAt);

Notice what is absent: navigation properties, lazy-loading proxies, secrets and methods that assume a database connection. The UI receives the data it needs, not the persistence graph.

During development, .NET 10 exposes renderer information that helps a component understand whether it is currently interactive. Use that information only where behaviour genuinely differs, such as suppressing a browser-only operation during prerendering. Do not scatter render-mode checks through business logic. Business rules should not care which renderer drew a button.

Put the page at the composition boundary

The page should coordinate smaller components and application services. It should not become the home of underwriting policy.

@page "/reviews/{ApplicationId:guid}"
@attribute [Authorize(Policy = Policies.ReviewLoans)]
@rendermode InteractiveServer
@inject ILoanReviewQueries Queries
@inject ILoanReviewCommands Commands

<PageTitle>Loan review</PageTitle>

@if (_loading)
{
    <p role="status">Loading the application…</p>
}
else if (_loadError is not null)
{
    <ErrorSummary Message="@_loadError" OnRetry="LoadAsync" />
}
else if (_model is not null)
{
    <LoanSummary Model="_model" />
    <ReviewNoteEditor Draft="_draft" OnSave="SaveNoteAsync" />
    <ReviewDecisionPanel Model="_model" OnDecide="DecideAsync" />
}

@code {
    [Parameter] public Guid ApplicationId { get; set; }

    private LoanReviewModel? _model;
    private ReviewNoteDraft _draft = new();
    private string? _loadError;
    private bool _loading;

    protected override async Task OnParametersSetAsync() => await LoadAsync();

    private async Task LoadAsync()
    {
        _loading = true;
        _loadError = null;
        try
        {
            _model = await Queries.GetAsync(ApplicationId);
            _draft.ApplicationId = ApplicationId;
            _draft.ExpectedVersion = _model.Version;
        }
        catch (LoanNotFoundException)
        {
            _loadError = "This application could not be found.";
        }
        finally
        {
            _loading = false;
        }
    }
}

This is illustrative rather than a paste-ready final page. A real query accepts a cancellation token, error reporting distinguishes forbidden from missing where appropriate, and the loading policy avoids stale responses if the route parameter changes quickly.

The useful boundary is visible: the page owns view state; the query creates a read model; the command service performs a state change; child components render a focused part and raise meaningful events. ReviewDecisionPanel should emit a decision request, not receive the database context.

Junior: Why not let every child inject the services it needs?
>
Senior: Sometimes that is fine. But a presentational child is easier to test and reuse when its inputs and outputs are explicit. Inject services at a feature boundary when the component truly owns that feature, not merely to save writing a parameter.

Model commands as business requests

A button click is a UI event. Approval is a business command. Keep that distinction in code:

public sealed record DecideLoanCommand(
    Guid ApplicationId,
    LoanDecision Decision,
    string? Reason,
    long ExpectedVersion,
    Guid RequestId);

public enum LoanDecision
{
    Approve,
    Decline
}

ExpectedVersion detects stale edits. RequestId supports idempotency: if the browser retries after losing the response, the application can recognise a command that already succeeded rather than approving twice or writing duplicate audit events.

The handler should follow a predictable sequence:

  1. Authenticate the caller.
  2. Authorise the operation against the actual application resource.
  3. Load the aggregate and compare its version.
  4. Apply domain rules.
  5. Persist the state transition and audit/outbox records atomically.
  6. Return a result shaped for the UI.
public async Task<DecisionResult> HandleAsync(
    DecideLoanCommand command,
    ClaimsPrincipal user,
    CancellationToken cancellationToken)
{
    var loan = await repository.GetForUpdateAsync(
        command.ApplicationId, cancellationToken);

    var authorised = await authorization.AuthorizeAsync(
        user, loan, Policies.DecideLoan);

    if (!authorised.Succeeded)
        throw new ForbiddenException();

    if (loan.Version != command.ExpectedVersion)
        return DecisionResult.Conflict(loan.Version);

    if (await requestLog.WasProcessedAsync(command.RequestId, cancellationToken))
        return DecisionResult.AlreadyProcessed(loan.Status, loan.Version);

    loan.RecordDecision(command.Decision, command.Reason, user.GetSubjectId());
    await unitOfWork.CommitAsync(cancellationToken);

    return DecisionResult.Success(loan.Status, loan.Version);
}

The real transaction design depends on the persistence technology. If a notification must be published, use an outbox or another reliable delivery mechanism rather than saving the decision and then hoping an unrelated network call succeeds. Do not hold a database transaction open while waiting for a user to confirm a modal.

On conflict, do not display “Something went wrong.” Tell the reviewer that the application changed, preserve their draft reason, reload the latest state, and show what they must reconsider. Optimistic concurrency is not only a database concern; it is a user-experience decision.

16. Forms: validate twice, explain once

The reviewer-note form needs client-friendly feedback and server authority. Data annotations can drive ordinary field validation:

public sealed class ReviewNoteDraft
{
    public Guid ApplicationId { get; set; }

    [Required]
    [StringLength(2_000, MinimumLength = 3)]
    public string Text { get; set; } = string.Empty;

    public long ExpectedVersion { get; set; }
}
<EditForm Model="Draft" OnValidSubmit="SubmitAsync" FormName="review-note">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <label for="review-note-text">Reviewer note</label>
    <InputTextArea id="review-note-text"
                   @bind-Value="Draft.Text"
                   aria-describedby="review-note-help" />
    <div id="review-note-help">Explain evidence, not assumptions.</div>
    <ValidationMessage For="() => Draft.Text" />

    <button type="submit" disabled="@_saving">
        @(_saving ? "Saving…" : "Save note")
    </button>
</EditForm>

An EditForm includes antiforgery support in the normal Blazor form pipeline. That is valuable, but do not reduce form security to a single token. The server still validates identity, permission, resource ownership, input length, allowed transitions and concurrency. Architecture matters too: a JSON API called with bearer tokens has different cross-site request-forgery characteristics from a cookie-authenticated form endpoint.

Server validation may discover facts unavailable to the component—for example, the application was locked by fraud operations. Return structured validation problems and associate field-specific errors with the relevant input. Use a summary for cross-field or business errors. Preserve the user's text unless there is a security reason not to.

Prevent accidental double submission in the interface, then make the command safe if duplication still occurs. Disabling the button improves usability, but it is not idempotency. Two tabs, a retrying proxy or a reconnect can still produce more than one request.

Junior: If validation ran in the component, why repeat it on the server?
>
Senior: Because every value originating outside the trusted process is input. Interactive WebAssembly runs on the user's machine, and even an Interactive Server event can be invoked with stale or manipulated state. Friendly validation helps the user; authoritative validation protects the system.
Treat free text according to where it will be rendered. Razor normally encodes text output, which is the safe default. Avoid MarkupString for user content. If a requirement genuinely permits HTML, sanitise with a carefully maintained policy and test malicious inputs. Encoding, sanitisation and validation solve different problems.

17. Authentication, authorisation and tenant isolation

Blazor uses ASP.NET Core authentication and authorisation. The subtlety is that UI state can make protection appear stronger than it is.

AuthorizeView is excellent for showing the appropriate controls:

<AuthorizeView Policy="@Policies.DecideLoan">
    <Authorized>
        <button @onclick="ApproveAsync">Approve</button>
    </Authorized>
    <NotAuthorized>
        <p>You can review this application but cannot approve it.</p>
    </NotAuthorized>
</AuthorizeView>

It does not secure the command by itself. Apply route/endpoint protection and resource-based authorisation in the command handler. The policy may need the loaded loan because a reviewer can approve only applications assigned to their team. A role name alone cannot express that rule safely.

For a multi-tenant system, derive the tenant from the authenticated identity or a trusted server mapping. Never trust TenantId merely because the browser posted it. Every query and command must be scoped, and caches must include tenant identity in their keys. Logs should include a safe tenant identifier for correlation without exposing personal data.

Interactive Server deserves another warning: a circuit can live much longer than one HTTP request. Services registered as scoped can live for the circuit rather than for a single event. Do not put mutable per-operation state in an injected scoped service and assume it disappears after a click. Do not keep an EF Core DbContext alive for the whole circuit. Prefer a context factory and create a short-lived context per unit of work:

public sealed class LoanReviewQueries(IDbContextFactory<LoansDbContext> factory)
{
    public async Task<LoanReviewModel> GetAsync(
        Guid id,
        string tenantId,
        CancellationToken cancellationToken = default)
    {
        await using var db = await factory.CreateDbContextAsync(cancellationToken);

        return await db.Loans
            .AsNoTracking()
            .Where(x => x.Id == id && x.TenantId == tenantId)
            .Select(x => new LoanReviewModel(
                x.Id,
                x.Applicant.DisplayName,
                x.RequestedAmount,
                x.Status.Name,
                x.AssignedReviewer.DisplayName,
                x.Version,
                x.Documents.Select(d => new ReviewDocumentModel(
                    d.Id, d.DisplayName, d.SizeInBytes, d.UploadedAt)).ToList()))
            .SingleAsync(cancellationToken);
    }
}

In production, avoid accepting the tenant string from an arbitrary caller as shown without a trusted resolver; it is present here to make the filter visible. A robust query service obtains a validated tenant context and has tests proving that records cannot cross tenant boundaries.

Revalidation matters for long-lived circuits. A user can lose a role or have an account disabled while a page remains open. Configure the authentication-state provider and application policy appropriately, and always re-authorise sensitive commands at execution time.

18. Prerendering, persistent state and duplicate work

Interactive components are commonly prerendered. The server first produces HTML for a fast initial response; later the component becomes interactive. That improves perceived performance and search visibility, but lifecycle code may execute in both phases.

If OnInitializedAsync calls a paid service or records “page viewed,” doing it twice is not harmless. Even a read query can produce a flash or unnecessary latency when interactivity starts.

Persistent component state lets prerendered data survive into the interactive phase. The shape of the API varies with framework version and scenario, so use the .NET 10 documentation for the exact registration style. The design principle is stable:

protected override async Task OnInitializedAsync()
{
    if (!ApplicationState.TryTakeFromJson<LoanReviewModel>(
            CacheKey, out var restored))
    {
        _model = await Queries.GetAsync(ApplicationId);
    }
    else
    {
        _model = restored;
    }
}

private void PersistForInteractiveRender()
{
    ApplicationState.PersistAsJson(CacheKey, _model);
}

Only persist data safe for the destination. In a WebAssembly-capable mode, persisted state reaches the browser. Do not put secrets, unrestricted entities or another user's data into it. Scope keys and state carefully when navigation changes parameters.

.NET 10 also supports persistent component state across enhanced navigation scenarios. This can avoid redundant work, but it does not turn component state into durable business storage. A draft that must survive a browser crash belongs in an explicit draft store, not only in a renderer optimisation.

Streaming rendering can send the stable shell while slow content completes. Design the streamed region so the initial HTML remains understandable: show a labelled status, reserve sensible space to reduce layout movement, and render failure and empty states. Streaming is not permission to make every component launch independent database queries. Batch or shape queries where that reduces load.

Junior: Can I fix duplicate work with a private _loaded boolean?
>
Senior: Only within one component instance. Prerendering and interactivity may involve different instances. Persist the result across the boundary or move the work to an idempotent, cached application service when that matches its meaning.

19. Circuits, reconnection and recoverable work

Interactive Server feels local, but the user's event depends on a network connection and a server-side circuit. A laptop sleeps, Wi-Fi changes, a load balancer closes an idle connection, or a deployment removes the process. Design those as expected operating conditions.

Classify page state:

  • Reconstructable state: the application summary can be queried again.
  • URL state: filters, selected tab or record ID that should survive refresh belong in the route or query string.
  • Draft state: unsaved note text deserves deliberate recovery.
  • Committed state: decisions belong in the database and audit trail.
  • Ephemeral state: an open tooltip can disappear safely.
Do not try to serialise an entire component graph. Store the smallest useful draft with an expiry, user ID, tenant ID and application ID. Browser storage may be suitable for non-sensitive draft text after a security review; a server-side draft service may be safer for confidential content. Clear it after a confirmed save.

The reconnection interface should answer three questions: what happened, what is happening now, and what can the user do? .NET 10 provides improved reconnection state support, including a components-reconnect-state-changed browser event. Use framework-supported hooks rather than polling internal DOM details.

A practical experience is:

  1. Show a non-destructive “Connection lost—trying to reconnect” status.
  2. Keep the visible draft instead of replacing the whole page.
  3. On successful reconnection, reload server state if it may be stale.
  4. If the circuit cannot be recovered, offer reload and explain whether the draft is saved.
  5. When replaying a command, rely on its request ID and expected version.
Never tell the user “Approval failed” merely because the response was lost. The server may have committed it. Query by request ID or reload the record before offering another approval.

This is distributed-systems reasoning in a UI. The browser, circuit, application service and database do not share one atomic moment.

20. JavaScript interop with ownership and disposal

Use JavaScript interop where the browser or a mature JavaScript library owns the capability: focus management, clipboard access, observers, charts or a document previewer. Wrap it behind a small component boundary.

Suppose the document panel uses a JavaScript module:

public sealed partial class DocumentPreview : IAsyncDisposable
{
    [Inject] private IJSRuntime JS { get; set; } = default!;
    [Parameter, EditorRequired] public string Url { get; set; } = string.Empty;

    private IJSObjectReference? _module;
    private ElementReference _host;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender)
            return;

        _module = await JS.InvokeAsync<IJSObjectReference>(
            "import", "./Components/DocumentPreview.razor.js");
        await _module.InvokeVoidAsync("mount", _host, Url);
    }

    public async ValueTask DisposeAsync()
    {
        if (_module is null)
            return;

        try
        {
            await _module.InvokeVoidAsync("unmount", _host);
            await _module.DisposeAsync();
        }
        catch (JSDisconnectedException)
        {
            // The Interactive Server circuit has already disconnected.
        }
    }
}

Browser interop belongs after rendering when it needs an element reference. The JavaScript module should remove observers and listeners during unmount. Avoid high-frequency chatty calls across the server circuit; keep a drag interaction or animation in JavaScript and send back a meaningful result.

Treat values crossing the boundary as untrusted. Validate URLs and identifiers on the server. Do not use interop to bypass the application's content-security policy or inject arbitrary HTML.

21. Documents and file uploads are a security feature

Loan review often includes uploads. This is where sample code can become dangerous when copied into production.

Do not trust the client filename for storage. Generate a server-side name, limit size, allow only required content types, inspect file signatures where appropriate, scan for malware, store outside the executable web root, and authorise every later download. A content-type header is a claim, not proof.

Stream uploads with explicit limits rather than loading an unbounded file into memory. Return progress carefully: extremely frequent progress events can overload an Interactive Server circuit. Record who uploaded the file and associate it with the tenant and application inside the same trusted workflow.

For downloads, use opaque identifiers and a server endpoint that checks access. A guessed blob URL must not reveal another applicant's documents. Set a safe download filename and content disposition; do not reflect raw header values from the upload.

The mentoring lesson is broader: whenever UI code makes a sensitive operation feel convenient, trace the request all the way to storage and back. Convenience must not erase the trust boundary.

22. Testing the workflow in layers

A reliable test strategy follows the boundaries we designed.

Domain tests prove transitions: a declined loan needs a reason; a final decision cannot be silently replaced; an unauthorised status transition is impossible through the domain API.

Application tests prove authorisation, concurrency, idempotency and transaction behaviour against realistic infrastructure. A duplicate RequestId should produce one audit entry. A stale ExpectedVersion should not overwrite the winner.

Component tests with bUnit prove rendering and interaction. Test useful behaviour rather than every CSS class:

[Fact]
public void DecisionPanel_DisablesApprove_WhenAlreadyFinal()
{
    using var context = new TestContext();
    var model = TestLoans.ReviewModel(status: "Declined");

    var cut = context.RenderComponent<ReviewDecisionPanel>(parameters =>
        parameters.Add(p => p.Model, model));

    Assert.True(cut.Find("button[data-action='approve']")
        .HasAttribute("disabled"));
}

That test supports usability, not security. A separate application test calls the command as an unauthorised principal and expects rejection.

Host integration tests use the real ASP.NET Core pipeline to verify authentication schemes, antiforgery behaviour, policy registration, API contracts and database mapping. Replace only external systems that would make the test nondeterministic.

Browser tests cover the few journeys where renderer and browser behaviour matter: load a deep link, enter a draft, submit a decision, navigate with validation errors, reconnect after a simulated interruption, and operate using keyboard alone. Keep this suite selective because it is slower and more fragile than domain tests.

Include failure tests:

  • query timeout displays a retryable state;
  • permission changes before submit;
  • two reviewers decide from the same version;
  • the response is lost after commit;
  • JavaScript module import fails;
  • a large or disallowed upload is rejected;
  • prerender and interactive activation do not duplicate a side effect.
Run accessibility checks, but also perform keyboard and screen-reader-informed review. Automated tools find missing labels and some contrast problems; they cannot decide whether the focus order explains the workflow.
Junior: Should every component have a bUnit test?
>
Senior: Test where failure matters and where logic exists. A tiny wrapper with no behaviour may be covered through its parent. The command that approves a loan deserves much stronger evidence than a decorative divider.

23. Accessibility is part of correctness

An underwriter may use a keyboard, zoom, speech input or a screen reader. They may also be tired, interrupted or working on a small display. Accessible design helps all of those conditions.

Use native controls first. A real already has keyboard and activation semantics. Associate every form control with a label. Move focus intentionally after a route change, dialog open or validation failure. Announce asynchronous status without making every background update noisy.

When a decision succeeds, focus a stable confirmation heading and make the new status visible in text, not colour alone. When validation fails, provide a summary linked to fields and keep the user's entries. A modal must trap focus correctly, close with expected controls, restore focus to its trigger and have an accessible name.

Loading skeletons should not masquerade as real content to assistive technology. Respect reduced-motion preferences. Test at 200% zoom and with narrow viewports. Ensure the document viewer has an alternative route to download or read accessible content.

The best component API makes accessibility difficult to forget. A reusable confirmation dialog can require a title and focus target. A form-field component can consistently connect hint, error and input identifiers. Reuse is valuable when it preserves semantics, not only colours.

24. Performance: measure the chosen execution model

Blazor performance advice becomes confusing when people mix hosting models. First identify where the expensive work occurs.

For static SSR, watch server response time, query count, payload size and caching. For Interactive Server, also watch active circuits, memory per circuit, event latency, connection failures and reconnection success. For WebAssembly, watch initial and cached download sizes, startup time, trimming/AOT trade-offs, API latency and main-thread work.

Do not optimise by instinct. Establish a journey-level budget: time until the loan summary is readable, time until decisions are interactive, and latency from submit to durable confirmation. Measure at representative network conditions and realistic concurrency.

Common improvements include shaping database projections, avoiding repeated lifecycle queries, virtualising genuinely large lists, using stable keys for changing collections, reducing unnecessary renders, and separating high-frequency browser interactions from server round trips. ShouldRender is a specialised tool, not the first answer. A cleaner state boundary often removes more work with less risk.

For WebAssembly, trimming can reduce downloads but reflection-heavy code may require configuration. Ahead-of-time compilation can improve CPU-heavy runtime performance while increasing build size and publication time. Make the trade using measurements from the real application. A form-driven line-of-business page may care more about payload than raw computation speed.

For Interactive Server, do not store large document buffers or unbounded histories in component fields. The cost multiplies by connected circuits. Prefer IDs and reloadable summaries; stream documents through dedicated endpoints.

25. Observability and privacy

Logs should help us reconstruct a review without exposing an applicant. Give each command a correlation ID or request ID and record safe fields such as application identifier, tenant identifier, actor subject, expected version, result category and duration. Avoid names, document contents, tokens and full reviewer notes.

Useful metrics include:

  • review-page load latency and failure rate;
  • decision-command latency, conflict rate and rejection rate;
  • circuit connection and reconnection outcomes;
  • active circuits and server memory;
  • API dependency and database latency;
  • upload rejection and scanning outcomes.
Traces connect the UI-triggered request to command handling, database calls and outbox publication. They are especially useful when the user saw a timeout but the decision committed. The trace and request ID help support distinguish “not processed” from “processed but confirmation lost.”

Create alerts around user impact, not every exception. A brief handled reconnect is different from a sustained inability to establish circuits. Dashboards should separate deployment versions so a regression is visible after rollout.

Audit events are not ordinary debug logs. Define retention, immutability, access and redaction requirements with the business and security teams. An audit record should explain the action without becoming an uncontrolled copy of sensitive data.

26. Deployment and scaling checklist

Before choosing infrastructure, say the render mode aloud. Interactive Server requires persistent real-time connections and server resources for circuits. A multi-instance deployment must support the connection and state model recommended by the hosting platform; evaluate session affinity and a managed SignalR service according to the topology and scale requirements. Do not copy a load-balancer recipe from a stateless JSON API and assume it fits.

Static SSR endpoints scale more like conventional server-rendered ASP.NET Core requests. WebAssembly moves UI execution to the browser but still depends on secure, scalable APIs. Auto combines concerns rather than removing them.

A safe release checklist for our workspace is:

  1. Publish using the production configuration and inspect trimming warnings.
  2. Run database migrations as a controlled deployment step, not independently from every app instance.
  3. Verify forwarded headers, HTTPS, cookie policy and data-protection key persistence.
  4. Confirm health endpoints distinguish process health from dependency readiness.
  5. Test a deep link directly, not only navigation from the home page.
  6. Test authenticated reconnect through the actual proxy or gateway.
  7. Confirm static assets have correct caching and versioned URLs.
  8. Exercise rolling deployment while a reviewer has an unsaved draft.
  9. Validate logs and traces contain correlation but no confidential form content.
  10. Rehearse rollback and verify old and new versions can coexist with the database schema.
When a deployment necessarily drops circuits, tell the user and protect drafts. Zero-downtime infrastructure does not guarantee zero interruption to every stateful UI session.

27. Three incidents I want you to diagnose

Incident one: the query runs twice

The team sees two identical query traces when the page first loads. They add a boolean field, but production still shows duplication.

Start with the renderer lifecycle. Prerendering creates initial HTML and interactive activation can create another component instance. A private boolean protects only one instance. Decide whether the data should be persisted across prerender, cached in an appropriately scoped query service, or loaded only when interactive. Confirm that the query is side-effect free regardless.

The lesson is not “disable prerendering everywhere.” Prerendering has user-experience benefits. Understand the boundary and make work safe.

Incident two: Reviewer B overwrites Reviewer A

Both users load version 12. Reviewer A approves, producing version 13. Reviewer B declines from the old screen, and the last write wins.

The fix crosses layers: include the expected version in the command, configure persistence concurrency, translate the conflict into a domain/application result, preserve Reviewer B's reason, reload current state and require a conscious new decision. Add a test with two contexts or command executions so the race is reproduced.

Do not “fix” it by disabling multiple browser tabs. Concurrency also comes from separate people and integrations.

Incident three: a reconnect creates two audit entries

The reviewer clicks Approve. The server commits but the connection drops before confirmation. The reconnected page offers Approve again, and the second command writes another event.

Use a stable request ID for the logical action, store its processing outcome in the same reliable boundary as the decision, and reload authoritative state after uncertain delivery. A UI _saving flag cannot survive every failure and cannot coordinate two processes.

These incidents are excellent interview discussions because they reveal whether someone understands components only as syntax or as participants in a distributed application.

28. Mentoring review: explain the design back to me

Before calling the feature complete, I would ask you these questions:

  1. Why is the review panel interactive, and why did you choose its render mode?
  2. Which data can cross the render boundary, and which data must never reach the browser?
  3. Where is approval authorised when the UI is bypassed?
  4. How do two reviewers avoid silent lost updates?
  5. What happens when the decision commits but its response is lost?
  6. Which state survives a reconnect, refresh or new device?
  7. What work can execute during both prerendering and interactive activation?
  8. How are database contexts scoped in an Interactive Server circuit?
  9. How does a keyboard user discover and correct validation errors?
  10. Which signals tell operations that reviewers cannot complete decisions?
If an answer is “Blazor handles it,” keep investigating. Frameworks provide mechanisms; the team still chooses policies and failure behaviour.

Your implementation exercise

Build a thin vertical slice before the entire workspace:

  • one deep-linkable review route;
  • one projected read model;
  • one resource-authorised decision command;
  • optimistic concurrency and an idempotency key;
  • a note form with client-friendly and server-authoritative validation;
  • draft recovery appropriate to the data sensitivity;
  • a component test, application integration test and browser journey;
  • structured telemetry with no applicant personal data.
Then run two deliberate failure drills. First, update the record from a second test client before submitting. Second, interrupt the connection immediately after submit. Write down what the reviewer sees and what the database contains. If either outcome is ambiguous, improve the contract before adding more screens.

Code-review checklist

Use this shorter list during pull requests:

  • Is the render mode explicit and justified?
  • Are entities kept behind server/application boundaries?
  • Are route, command and resource permissions enforced on the server?
  • Are long-lived circuit services free of unsafe mutable operation state?
  • Are lifecycle methods safe under prerendering and parameter changes?
  • Are event handlers cancellable or protected against stale completion where needed?
  • Are commands concurrency-aware and retry-safe?
  • Does every loading, empty, failure, conflict and success state have useful UI?
  • Are JS modules and .NET callbacks disposed safely?
  • Are forms labelled, keyboard-operable and focused deliberately after errors?
  • Do tests cover rules below the component as well as visible behaviour?
  • Can telemetry explain an uncertain outcome without leaking sensitive data?

Continue the learning path

If render modes and component boundaries are new, revisit the earlier sections of this guide and implement the slice twice: once with Interactive Server and once with an API-backed WebAssembly client. The business command should remain conceptually the same while transport and state ownership change.

For deeper language and runtime work, continue with the C# 14 and .NET 10 Modern Development Mentoring Guide. For browser fundamentals that still matter when writing C#, use How a Browser Works and HTTP and the Web. For an architectural comparison, read the Angular Modern Web Development Mentoring Guide or the Full-Stack React, TypeScript and Node Mentoring Guide and identify which concerns are framework-specific and which are universal.

29. Asynchronous work, cancellation and stale results

Blazor makes asynchronous event handlers pleasant to write, but await does not preserve the meaning of the screen while work is in flight. The user can navigate to another application, change a filter, click again or disconnect before a response arrives.

Consider a component that loads whenever its route parameter changes:

protected override async Task OnParametersSetAsync()
{
    _model = await Queries.GetAsync(ApplicationId);
}

If application A is slow and the user quickly navigates to application B, the response for A can arrive last and replace B's view. The component has completed valid asynchronous work but produced an invalid user outcome.

Use cancellation where the dependency supports it and also verify that a result still belongs to the current request:

private CancellationTokenSource? _loadCts;
private long _loadSequence;

protected override async Task OnParametersSetAsync()
{
    _loadCts?.Cancel();
    _loadCts?.Dispose();
    _loadCts = new CancellationTokenSource();

    var requestedId = ApplicationId;
    var sequence = ++_loadSequence;

    try
    {
        var result = await Queries.GetAsync(requestedId, _loadCts.Token);

        if (sequence == _loadSequence && requestedId == ApplicationId)
            _model = result;
    }
    catch (OperationCanceledException) when (_loadCts.IsCancellationRequested)
    {
        // Superseded navigation is expected, not a user-facing failure.
    }
}

public void Dispose()
{
    _loadCts?.Cancel();
    _loadCts?.Dispose();
}

In real code, avoid reading a field in a catch filter after another path may have disposed or replaced it; capture the local token source and dispose it with clear ownership. The sample emphasises the two protections: cancellation reduces wasted work, while sequence/identity checks prevent stale display even when cancellation arrives too late.

Do not pass a component-disposal token blindly into a command after the server has accepted it. Cancellation cannot reliably undo a committed decision. Distinguish cancelling a query the user no longer needs from abandoning confirmation of a business operation. Once a decision is submitted, retain its request ID and reconcile the result if delivery becomes uncertain.

Loading indicators also need discipline. Avoid flickering a full-page spinner for every quick refresh. Keep stable content visible when safe, mark the region busy, and prevent actions based on a stale model. If an operation takes long enough, explain what is happening. If it can be cancelled safely, offer cancellation; if it cannot, do not show a button that merely hides the progress.

Exceptions from event callbacks should reach an intentional error boundary or be translated to local UI state. Do not use an empty catch (Exception) to keep a circuit alive. It conceals defects and may leave the component claiming that a failed command succeeded. Catch errors you can handle, log unexpected ones with correlation, and render a recovery path.

Junior: Can I use async void for a click handler because Razor accepts it?
>
Senior: Return Task. The renderer can await it, observe exceptions and schedule rendering correctly. Reserve async void for the narrow .NET event patterns that require it, then delegate immediately to a task-returning method.

30. Deliver the vertical slice in reviewable stages

A production feature becomes safer when each change has one demonstrable purpose. I would guide the team through this sequence.

Stage one: walking skeleton

Create the route, authorisation policy, read-model query and empty page states. Deploy it behind a feature flag to the test environment. Confirm that a direct URL works, unauthorised users are rejected by the server, and the query is tenant-scoped. Add tracing now; it is much harder to explain later failures if observability is postponed.

The pull request should contain no decision command yet. Its review question is simple: can the correct reviewer safely see the correct record under the chosen render mode?

Stage two: one authoritative command

Add approval with expected version, resource authorisation, domain validation, idempotency and audit/outbox persistence. Exercise it through an application-level integration test before connecting the button. This proves that security and correctness do not depend on component behaviour.

Then connect a minimal panel and translate every result explicitly: success, forbidden, validation rejection, concurrency conflict, duplicate/already processed and unexpected dependency failure. A result union or well-defined exception mapping is better than returning bool, because false cannot tell the UI how to recover.

Stage three: resilient editing

Add the note editor, accessible validation, safe draft recovery and navigation protection. Test what happens if the reviewer refreshes, follows another link or loses the circuit. Avoid browser prompts for every harmless navigation; warn only when meaningful unsaved work exists.

Draft storage needs a lifecycle. Decide when a draft expires, who may read it, what happens after reassignment and how it is removed after success. “Stored locally” is not a complete retention policy.

Stage four: documents and JavaScript boundary

Introduce one document flow after the authorisation and storage design passes security review. Keep the preview library behind a wrapper, dispose it, and provide a download fallback. Measure the document experience without placing document bytes in circuit state.

Stage five: operational rehearsal

Test behind the same proxy and identity provider used in production. Run representative concurrent circuits, inspect memory, rotate an instance, interrupt a connection and deploy a new version while a draft is open. Confirm telemetry distinguishes a user cancellation, concurrency conflict, authorisation rejection and system failure.

The release decision should cite evidence: test results, accessibility review, performance measurements, threat-model outcomes and rollback readiness. “It worked on localhost” proves only a small part of the system.

31. Architecture decisions worth recording

Short architecture decision records prevent the next developer from undoing a deliberate constraint because it looks accidental. For this feature, record at least:

  • why Interactive Server was selected and which observation would trigger reconsideration;
  • where server-only data stops and serialisable UI models begin;
  • how tenant context is established and enforced;
  • how command request IDs and versions are generated and stored;
  • where drafts live, how long they remain and how confidential data is protected;
  • which operations are safe to retry;
  • how a deployment or unrecoverable circuit affects active work;
  • which browser capability requires JavaScript and who owns its cleanup.
Each decision should include context, choice, consequences and alternatives. For example, choosing Interactive Server reduces initial client download and allows direct server services, but it increases connection and circuit responsibilities. That consequence is not a failure; it is a cost the team has consciously accepted and will measure.

Avoid writing an encyclopaedia. Link the decision to code, dashboards and tests. Revisit it when assumptions change—for example, when field reviewers require offline work or international latency makes server interaction uncomfortable.

32. Final mentoring challenge

Take the loan-review slice and make three variations on paper before writing more code.

First, redesign it for Interactive WebAssembly. Identify the API contracts, token handling, browser-visible data, offline limitations and additional server endpoints. Keep approval authoritative on the server.

Second, redesign it as mostly static SSR with ordinary form posts. Ask whether the product truly needs continuous interactivity. You may discover that the simpler execution model satisfies most requirements and degrades more predictably.

Third, design for ten times the concurrent reviewers. Estimate circuit memory, connection capacity, database pressure and deployment behaviour. State which measurements would replace your estimates.

For each design, produce a one-page table with first-load experience, ongoing event latency, server cost, client download, offline capability, security boundary, state recovery and operational complexity. There is no universally winning mode. The professional answer is a choice tied to evidence and constraints.

Finally, explain the design to another developer without using the phrase “because Blazor does it.” If you can describe where code executes, where state lives, who authorises a command, how failure is reconciled and how the team observes it, you understand the application rather than merely its syntax.

Definition of done for the exercise

Call the slice complete only when a reviewer can open a direct link, understand loading and failure states, complete the task using a keyboard, recover from an interrupted connection, and receive an unambiguous outcome. The server must reject unauthorised and stale commands even when the component is bypassed. A repeated request must not repeat the business effect. Another tenant's record must remain inaccessible through both queries and commands.

The automated evidence should include domain rules, policy enforcement, tenant isolation, concurrency, idempotency, component behaviour and one browser-level journey. The operational evidence should show useful traces and metrics, safe logs, a tested production proxy path, a deployment recovery story and an alert connected to user impact. The security evidence should cover input handling, document access, antiforgery assumptions, secret management and sensitive-data retention.

Record any conscious limitation. Perhaps offline work is unsupported, drafts expire after eight hours, or an unrecoverable circuit requires reload. A clear limitation with a recovery path is better than accidental behaviour.

Then ask a developer who did not build the feature to follow the runbook during a simulated uncertain approval. If they can determine whether it committed without inspecting the database manually, the system is supportable. If they cannot, improve correlation and reconciliation before release.

33. Version-aware references

Blazor evolves quickly, especially around rendering, navigation, state persistence and reconnect behaviour. The production guidance in this chapter is aligned to .NET 10 documentation available when this guide was revised. Before adopting an API, confirm the documentation view matches the framework version in your project.

Primary references:

Documentation supports the framework facts; it cannot choose your confidentiality rules, availability target or user experience. Record those decisions in an architecture note beside the code.

What I want you to take away

Blazor is not just “C# instead of JavaScript.”

Blazor is a modern .NET UI platform with multiple execution models.

If I were mentoring you before an exam, interview or new Blazor project, I would ask you to master this chain:

Blazor starts with Razor components. Components render UI and hold state. Components can receive parameters, raise EventCallback, use RenderFragment, inject services and respond to lifecycle events. A Blazor app can render using static SSR, streaming SSR, Interactive Server, WebAssembly or Auto. The render mode decides where code runs, how fast the page starts, how interactivity works, how scaling behaves, and whether APIs are required.

Data should be separated into entities and DTOs. Server-rendered components can use server services directly, but WebAssembly needs APIs. Forms use EditForm, input components and validation. Authentication protects pages and APIs. CSS isolation keeps component styling clean. JavaScript interop exists for browser APIs and third-party libraries. State can live locally, in the URL, on the server, in browser storage or in a state container. Testing with bUnit gives confidence. Tracing, metrics and logs help diagnose real systems. Deployment depends on hosting mode. WebAssembly brings AOT, trimming, lazy loading and PWA options. MAUI Blazor Hybrid lets Blazor move into native apps.

The mature interview answer is this:

“Blazor lets .NET developers build component-based web UIs using C# and Razor, but the real skill is choosing the correct render mode, designing clean components, managing state deliberately, separating DTOs from persistence models, securing both UI and APIs, using JavaScript interop only where appropriate, testing components, and deploying based on the hosting model.”
That is the standard I use for professional Blazor thinking.

Applied In

The thinking in this article has been applied throughout my enterprise portfolio, where architecture, workflows, permissions, notifications, reporting and modular design are all built around real business operations rather than isolated technical features.

View Continuous Learning →

Use this journal entry for recall practice

Compare your explanation with the questions and working answers in my Practice Room.

Practise Blazor and .NET interview questions →
Afzal Ahmed

Faz Ahmed

Senior Full Stack Engineer & Technical Lead

A hands-on engineer with 15+ years in commercial software. I publish what I am studying, revising and testing so visitors can see both established experience and learning still in progress.

How would you approach this problem? I'd love to hear your thoughts or continue the discussion.

Connect on LinkedIn →