Beginner

Blazor Fundamentals – Complete Guide

Blazor end to end — render modes, components, EF Core, state, forms, WASM, JS interop and testing.

Course: Blazor Fundamentals (.NET 8/9) Demo Application: Bethany’s Pie Shop HRM Level: Intermediate | Estimated Duration: ~6h


Table of Contents

  1. Blazor Overview and Render Modes
  2. Blazor Web App Project Structure
  3. Razor Components — Advanced Fundamentals
  4. Data Access with EF Core
  5. State Management
  6. Forms and Validation
  7. Blazor WebAssembly (WASM)
  8. JavaScript Interop
  9. Authentication and Authorization
  10. Testing with bUnit
  11. Advanced Components — Virtualize and QuickGrid
  12. Deployment on Azure
  13. Best Practices
  14. Practical Exercises
  15. Glossary

1. Blazor Overview and Render Modes

1.1 Blazor — The full-stack C# framework

Blazor allows building interactive web applications using C# instead of JavaScript. It integrates natively into the ASP.NET Core ecosystem.

flowchart LR
    subgraph Blazor["Blazor Web App"]
        UI[Razor Components\n.razor] --> CS[C# Code\n.razor.cs]
        CS --> Data[EF Core\nDatabase]
        CS --> API[HTTP Client\nExternal APIs]
    end
    
    Browser[Browser] -->|"HTML + JS"| Blazor
    Server[.NET Server] -->|"SignalR / WASM"| Blazor

1.2 The 4 Render Modes

Render ModeExecutionUse CaseInteractivity
Static SSRServer → Static HTMLPublic pages, SEO❌ None
Interactive ServerServer via SignalRInternal apps, direct DB access✅ Full
Interactive WASMBrowser via WebAssemblyPWA, offline apps✅ Full
Interactive AutoServer then WASMGeneral applications✅ Full
sequenceDiagram
    participant B as Browser
    participant S as Server
    
    rect rgb(200, 230, 200)
        Note over B,S: Interactive Auto Mode
        B->>S: GET /employees (first visit)
        S-->>B: HTML + Server mode enabled
        B->>S: SignalR connection established
        Note over B: Server mode active
        B->>B: WASM bundle downloaded in background
        Note over B: Switch to WASM mode
        B->>B: Subsequent requests handled in local WASM
    end

2. Blazor Web App Project Structure

2.1 Generated Files and Their Role

// Program.cs — Application configuration
var builder = WebApplication.CreateBuilder(args);

// Razor + interactive mode services
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

// Business services
builder.Services.AddDbContextFactory<AppDbContext>(opts =>
    opts.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddScoped<IEmployeeDataService, EmployeeDataService>();
builder.Services.AddScoped<IEmployeeRepository, EmployeeRepository>();

// Shared state (lifetime = SignalR circuit duration)
builder.Services.AddScoped<ApplicationState>();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAntiforgery();

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

app.Run();

2.2 App.razor — HTML Entry Point

@* Components/App.razor *@
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="/" />
    <link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
    <link rel="stylesheet" href="app.css" />
    @* Automatically includes isolated CSS from components *@
    <link rel="stylesheet" href="BethanysPieShopHRM.styles.css" />
    <HeadOutlet />
</head>
<body>
    <Routes />
    <script src="_framework/blazor.web.js"></script>
</body>
</html>

3. Razor Components — Advanced Fundamentals

3.1 Advanced Parameters and Component Communication

flowchart TD
    Parent[EmployeeOverview\nParent Component]
    Child1[EmployeeCard\nChild Component]
    Child2[QuickViewPopup\nChild Component]
    
    Parent -->|"Employee parameter"| Child1
    Child1 -->|"EventCallback OnQuickViewClicked"| Parent
    Parent -->|"Employee + IsVisible parameters"| Child2
    
    subgraph DataFlow["Data Flow"]
        direction LR
        P["Parent to Child\n→ [Parameter]"] 
        E["Child to Parent\n→ EventCallback"]
    end
// EmployeeCard.razor.cs — Parent ↔ Child Communication
public partial class EmployeeCard
{
    // ─── INCOMING PARAMETER (Parent → Child) ────────────────────
    [Parameter, EditorRequired]
    public Employee Employee { get; set; } = default!;
    
    [Parameter]
    public bool ShowActions { get; set; } = true;
    
    // ─── OUTGOING EVENT (Child → Parent) ────────────────────────
    // EventCallback is null-safe and triggers StateHasChanged() automatically
    [Parameter]
    public EventCallback<int> OnQuickViewClicked { get; set; }
    
    [Parameter]
    public EventCallback<Employee> OnEmployeeSelected { get; set; }
    
    // ─── CASCADING PARAMETER (ancestor → deep descendant) ───────
    [CascadingParameter]
    private ApplicationState? AppState { get; set; }
    
    // ─── EVENT HANDLER ──────────────────────────────────────────
    private async Task HandleQuickViewClick()
    {
        if (OnQuickViewClicked.HasDelegate)
        {
            await OnQuickViewClicked.InvokeAsync(Employee.EmployeeId);
        }
    }
}

3.2 DynamicComponent — Dynamically Loaded Components

// Components/Pages/Home.razor.cs
public partial class Home
{
    // Widgets to display dynamically on the dashboard
    private List<(Type ComponentType, Dictionary<string, object>? Parameters)> _widgets = new();
    
    protected override void OnInitialized()
    {
        _widgets = new List<(Type, Dictionary<string, object>?)>
        {
            (typeof(EmployeeCountWidget), new Dictionary<string, object>
            {
                { "ShowTrend", true },
                { "Title", "Total Headcount" }
            }),
            (typeof(InboxWidget), new Dictionary<string, object>
            {
                { "MaxMessages", 5 }
            }),
            (typeof(HolidayWidget), null)
        };
    }
}
@* Components/Pages/Home.razor *@
@page "/"

<div class="dashboard row g-3">
    @foreach (var (componentType, parameters) in _widgets)
    {
        <div class="col-md-4">
            @* DynamicComponent instantiates the type at runtime *@
            <DynamicComponent Type="@componentType" 
                             Parameters="@parameters" />
        </div>
    }
</div>

3.3 ErrorBoundary — Error Handling

@* Wrap potentially failing components *@
@foreach (var employee in Employees!)
{
    <ErrorBoundary>
        <ChildContent>
            <EmployeeCard Employee="@employee" 
                         OnQuickViewClicked="@ShowQuickView" />
        </ChildContent>
        <ErrorContent Context="ex">
            <div class="alert alert-warning d-flex align-items-center" role="alert">
                <i class="bi bi-exclamation-triangle me-2"></i>
                <div>
                    <strong>Invalid Employee</strong> — This employee's data is corrupted.
                    <br/>
                    @* Employee ID for easier debugging *@
                    ID: @employee.EmployeeId
                </div>
            </div>
        </ErrorContent>
    </ErrorBoundary>
}

4. Data Access with EF Core

4.1 DbContext and Migrations

// Data/AppDbContext.cs
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
    
    public DbSet<Employee> Employees { get; set; }
    public DbSet<Country> Countries { get; set; }
    public DbSet<JobCategory> JobCategories { get; set; }
    public DbSet<TimeRegistration> TimeRegistrations { get; set; }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Relationship configuration
        modelBuilder.Entity<Employee>()
            .HasOne(e => e.Country)
            .WithMany()
            .HasForeignKey(e => e.CountryId)
            .OnDelete(DeleteBehavior.Restrict);
        
        modelBuilder.Entity<Employee>()
            .HasMany(e => e.TimeRegistrations)
            .WithOne(t => t.Employee)
            .HasForeignKey(t => t.EmployeeId)
            .OnDelete(DeleteBehavior.Cascade);
        
        // Seed data (immutable)
        modelBuilder.Entity<Country>().HasData(
            new Country { CountryId = 1, CountryName = "Belgium", CountryCode = "BE" },
            new Country { CountryId = 2, CountryName = "France", CountryCode = "FR" },
            new Country { CountryId = 3, CountryName = "Netherlands", CountryCode = "NL" }
        );
        
        modelBuilder.Entity<JobCategory>().HasData(
            new JobCategory { JobCategoryId = 1, JobCategoryName = "Developer" },
            new JobCategory { JobCategoryId = 2, JobCategoryName = "UX Designer" },
            new JobCategory { JobCategoryId = 3, JobCategoryName = "Project Manager" }
        );
    }
}

Migration commands:

# Package Manager Console in Visual Studio
# Create an initial migration
Add-Migration InitialMigration

# Create a migration after model changes
Add-Migration AddTimeRegistrations

# Apply migrations to the DB
Update-Database

# Revert the last migration
Remove-Migration

# .NET CLI (cross-platform)
dotnet ef migrations add InitialMigration
dotnet ef database update

4.2 Complete Repository Pattern

// Contracts/Repository/IEmployeeRepository.cs
public interface IEmployeeRepository
{
    Task<IEnumerable<Employee>> GetAllEmployeesAsync();
    Task<Employee?> GetEmployeeByIdAsync(int employeeId);
    Task<Employee> CreateEmployeeAsync(Employee employee);
    Task<Employee> UpdateEmployeeAsync(Employee employee);
    Task DeleteEmployeeAsync(int employeeId);
    Task<bool> EmployeeExistsAsync(int employeeId);
    Task<IEnumerable<Employee>> SearchEmployeesAsync(string searchTerm);
}

// Repositories/EmployeeRepository.cs
public class EmployeeRepository : IEmployeeRepository
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;
    
    public EmployeeRepository(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }
    
    public async Task<IEnumerable<Employee>> GetAllEmployeesAsync()
    {
        await using var ctx = await _contextFactory.CreateDbContextAsync();
        return await ctx.Employees
            .Include(e => e.Country)
            .Include(e => e.JobCategory)
            .OrderBy(e => e.LastName).ThenBy(e => e.FirstName)
            .AsNoTracking() // Performance: no tracking for reads
            .ToListAsync();
    }
    
    public async Task<Employee?> GetEmployeeByIdAsync(int employeeId)
    {
        await using var ctx = await _contextFactory.CreateDbContextAsync();
        return await ctx.Employees
            .Include(e => e.Country)
            .Include(e => e.JobCategory)
            .Include(e => e.TimeRegistrations)
            .AsNoTracking()
            .FirstOrDefaultAsync(e => e.EmployeeId == employeeId);
    }
    
    public async Task<Employee> CreateEmployeeAsync(Employee employee)
    {
        await using var ctx = await _contextFactory.CreateDbContextAsync();
        ctx.Employees.Add(employee);
        await ctx.SaveChangesAsync();
        return employee;
    }
    
    public async Task<Employee> UpdateEmployeeAsync(Employee employee)
    {
        await using var ctx = await _contextFactory.CreateDbContextAsync();
        var existing = await ctx.Employees
            .FindAsync(employee.EmployeeId)
            ?? throw new KeyNotFoundException($"Employee {employee.EmployeeId} not found.");
        
        ctx.Entry(existing).CurrentValues.SetValues(employee);
        await ctx.SaveChangesAsync();
        return existing;
    }
    
    public async Task DeleteEmployeeAsync(int employeeId)
    {
        await using var ctx = await _contextFactory.CreateDbContextAsync();
        var employee = await ctx.Employees.FindAsync(employeeId);
        if (employee is not null)
        {
            ctx.Employees.Remove(employee);
            await ctx.SaveChangesAsync();
        }
    }
    
    public async Task<bool> EmployeeExistsAsync(int employeeId)
    {
        await using var ctx = await _contextFactory.CreateDbContextAsync();
        return await ctx.Employees.AnyAsync(e => e.EmployeeId == employeeId);
    }
    
    public async Task<IEnumerable<Employee>> SearchEmployeesAsync(string searchTerm)
    {
        await using var ctx = await _contextFactory.CreateDbContextAsync();
        var lower = searchTerm.ToLower();
        return await ctx.Employees
            .Include(e => e.Country)
            .Include(e => e.JobCategory)
            .Where(e => e.FirstName.ToLower().Contains(lower) ||
                        e.LastName.ToLower().Contains(lower) ||
                        e.Email.ToLower().Contains(lower))
            .AsNoTracking()
            .ToListAsync();
    }
}

5. State Management

5.1 Why State Management Is Necessary

flowchart LR
    subgraph Problem["Problem Without State Management"]
        C1[Component A\nloads employees] -->|"Navigation"| C2[Component B]
        C2 -->|"Back"| C3[Component A\nreloaded = data lost]
    end
    
    subgraph Solution["Solution with ApplicationState"]
        S[ApplicationState\nScoped Service] --> C4[Component A]
        S --> C5[Component B]
        note["Data persists\nthroughout the session"]
    end

5.2 ApplicationState — Shared Service

// State/ApplicationState.cs
public class ApplicationState
{
    // ─── NAVIGATION STATE ────────────────────────────────────────
    public Employee? SelectedEmployee { get; private set; }
    public string SearchTerm { get; private set; } = string.Empty;
    public int CurrentPage { get; private set; } = 1;
    
    // ─── MESSAGE STATE ───────────────────────────────────────────
    public int NumberOfMessages { get; private set; } = 0;
    
    // ─── CHANGE EVENTS ───────────────────────────────────────────
    // Components can subscribe to be notified of changes
    public event Action? OnChange;
    
    // ─── MUTATION METHODS ────────────────────────────────────────
    public void SelectEmployee(Employee employee)
    {
        SelectedEmployee = employee;
        NotifyStateChanged();
    }
    
    public void UpdateSearchTerm(string term)
    {
        SearchTerm = term;
        CurrentPage = 1; // Reset pagination on search
        NotifyStateChanged();
    }
    
    public void SetPage(int page)
    {
        CurrentPage = page;
        NotifyStateChanged();
    }
    
    public void UpdateMessageCount(int count)
    {
        NumberOfMessages = count;
        NotifyStateChanged();
    }
    
    private void NotifyStateChanged() => OnChange?.Invoke();
}

Using in a component:

@* Component A — Modifies state *@
@rendermode InteractiveServer
@inject ApplicationState AppState
@implements IDisposable

<div>
    <p>Messages: @AppState.NumberOfMessages</p>
    <button @onclick="LoadMessages">Load messages</button>
</div>

@code {
    protected override void OnInitialized()
    {
        // Subscribe to state changes
        AppState.OnChange += StateHasChanged;
    }
    
    private async Task LoadMessages()
    {
        var count = await MessageService.GetUnreadCountAsync();
        AppState.UpdateMessageCount(count);
        // All subscribed components update automatically!
    }
    
    public void Dispose()
    {
        // IMPORTANT: Unsubscribe to avoid memory leaks
        AppState.OnChange -= StateHasChanged;
    }
}

6. Forms and Validation

6.1 EditForm and Input Components

flowchart LR
    EditForm[EditForm\nwith Model=Employee] --> 
    DataAnnotations[DataAnnotationsValidator] -->
    Summary[ValidationSummary] 
    
    EditForm --> InputText[InputText\nfor strings]
    EditForm --> InputNumber[InputNumber\nfor numbers]
    EditForm --> InputDate[InputDate\nfor dates]
    EditForm --> InputCheckbox[InputCheckbox\nfor bool]
    EditForm --> InputSelect[InputSelect\nfor lists]
@* Components/Pages/AddEmployee.razor *@
@page "/employees/add"
@rendermode InteractiveServer
@inject IEmployeeDataService EmployeeDataService
@inject NavigationManager NavManager

<PageTitle>Add Employee</PageTitle>
<h1>Add Employee</h1>

<EditForm Model="@_newEmployee" OnValidSubmit="HandleValidSubmit" 
          FormName="AddEmployeeForm">
    @* Enable validation via DataAnnotations *@
    <DataAnnotationsValidator />
    
    @* Show a summary of all errors *@
    <ValidationSummary class="alert alert-danger" />

    <div class="row g-3">
        @* First Name *@
        <div class="col-md-6">
            <label for="firstName" class="form-label">First Name *</label>
            <InputText id="firstName" 
                      @bind-Value="_newEmployee.FirstName" 
                      class="form-control"
                      placeholder="Enter first name" />
            @* Field-specific error message *@
            <ValidationMessage For="@(() => _newEmployee.FirstName)" class="text-danger" />
        </div>
        
        @* Last Name *@
        <div class="col-md-6">
            <label for="lastName" class="form-label">Last Name *</label>
            <InputText id="lastName" 
                      @bind-Value="_newEmployee.LastName" 
                      class="form-control" />
            <ValidationMessage For="@(() => _newEmployee.LastName)" class="text-danger" />
        </div>
        
        @* Email *@
        <div class="col-12">
            <label for="email" class="form-label">Email *</label>
            <InputText id="email" 
                      @bind-Value="_newEmployee.Email" 
                      type="email"
                      class="form-control" />
            <ValidationMessage For="@(() => _newEmployee.Email)" class="text-danger" />
        </div>
        
        @* Hire Date *@
        <div class="col-md-6">
            <label for="hireDate" class="form-label">Hire Date *</label>
            <InputDate id="hireDate" 
                      @bind-Value="_newEmployee.HireDate" 
                      class="form-control" />
            <ValidationMessage For="@(() => _newEmployee.HireDate)" class="text-danger" />
        </div>
        
        @* Job Category *@
        <div class="col-md-6">
            <label for="jobCategory" class="form-label">Position *</label>
            <InputSelect id="jobCategory" 
                        @bind-Value="_newEmployee.JobCategoryId" 
                        class="form-select">
                <option value="0">-- Select a position --</option>
                @foreach (var category in _jobCategories)
                {
                    <option value="@category.JobCategoryId">@category.JobCategoryName</option>
                }
            </InputSelect>
            <ValidationMessage For="@(() => _newEmployee.JobCategoryId)" class="text-danger" />
        </div>
        
        @* Holiday mode *@
        <div class="col-12">
            <div class="form-check">
                <InputCheckbox id="isOnHoliday" 
                              @bind-Value="_newEmployee.IsOnHoliday" 
                              class="form-check-input" />
                <label for="isOnHoliday" class="form-check-label">
                    On holiday
                </label>
            </div>
        </div>
    </div>
    
    <div class="mt-4 d-flex gap-2">
        <button type="submit" class="btn btn-primary" disabled="@_isSaving">
            @if (_isSaving) { <span class="spinner-border spinner-border-sm me-2"></span> }
            @(_isSaving ? "Saving..." : "Save")
        </button>
        <a href="/employees" class="btn btn-outline-secondary">Cancel</a>
    </div>
</EditForm>

@if (_saveError is not null)
{
    <div class="alert alert-danger mt-3">@_saveError</div>
}
// AddEmployee.razor.cs
public partial class AddEmployee
{
    [Inject] private IEmployeeDataService EmployeeDataService { get; set; } = default!;
    [Inject] private IJobCategoryDataService JobCategoryService { get; set; } = default!;
    [Inject] private NavigationManager NavManager { get; set; } = default!;
    
    private Employee _newEmployee = new()
    {
        HireDate = DateTime.Today,
        IsOnHoliday = false
    };
    
    private IEnumerable<JobCategory> _jobCategories = Enumerable.Empty<JobCategory>();
    private bool _isSaving = false;
    private string? _saveError;
    
    protected override async Task OnInitializedAsync()
    {
        _jobCategories = await JobCategoryService.GetAllJobCategoriesAsync();
    }
    
    private async Task HandleValidSubmit()
    {
        _isSaving = true;
        _saveError = null;
        
        try
        {
            await EmployeeDataService.CreateEmployeeAsync(_newEmployee);
            NavManager.NavigateTo("/employees");
        }
        catch (Exception ex)
        {
            _saveError = $"Error while saving: {ex.Message}";
        }
        finally
        {
            _isSaving = false;
        }
    }
}

6.2 Model with DataAnnotations

// BethanysPieShopHRM.Shared/Models/Employee.cs
public class Employee
{
    public int EmployeeId { get; set; }
    
    [Required(ErrorMessage = "First name is required.")]
    [StringLength(50, MinimumLength = 2, 
        ErrorMessage = "First name must be between 2 and 50 characters.")]
    public string FirstName { get; set; } = string.Empty;
    
    [Required(ErrorMessage = "Last name is required.")]
    [StringLength(50, MinimumLength = 2,
        ErrorMessage = "Last name must be between 2 and 50 characters.")]
    public string LastName { get; set; } = string.Empty;
    
    [Required(ErrorMessage = "Email address is required.")]
    [EmailAddress(ErrorMessage = "Email address is not valid.")]
    public string Email { get; set; } = string.Empty;
    
    [Phone(ErrorMessage = "Phone number is not valid.")]
    public string? PhoneNumber { get; set; }
    
    [Required(ErrorMessage = "Hire date is required.")]
    [DataType(DataType.Date)]
    [Range(typeof(DateTime), "2000-01-01", "2030-12-31",
        ErrorMessage = "Hire date must be between 2000 and 2030.")]
    public DateTime HireDate { get; set; }
    
    [Range(1, int.MaxValue, ErrorMessage = "Please select a job category.")]
    public int JobCategoryId { get; set; }
    
    public bool IsOnHoliday { get; set; }
    
    // Navigation properties
    public int CountryId { get; set; }
    public Country? Country { get; set; }
    public JobCategory? JobCategory { get; set; }
    public ICollection<TimeRegistration> TimeRegistrations { get; set; } = new List<TimeRegistration>();
    
    // Computed properties (not stored in DB)
    public string FullName => $"{FirstName} {LastName}";
}

7. Blazor WebAssembly (WASM)

7.1 WASM Standalone vs WASM in Blazor Web App

flowchart TB
    subgraph Standalone["WASM Standalone App"]
        WASM[Blazor WASM] -->|"HTTP calls"| API[ASP.NET Core API\nor other backend]
        note1["Entire app in the browser\nNo Blazor server"]
    end
    
    subgraph WebApp["Blazor Web App (Auto/WASM mode)"]
        Server[Blazor Server] -->|"First render"| Browser[Browser]
        Client[Blazor WASM Client] -->|"Subsequent renders"| Browser
        Client -->|"HTTP calls"| LocalAPI[Local APIs\non the Host]
    end
    
    style Standalone fill:#FFE4B5
    style WebApp fill:#B5FFB5

7.2 WASM Configuration in Blazor Web App

// BethanysPieShopHRM.Client/Program.cs
// Code running only on the WASM side

var builder = WebAssemblyHostBuilder.CreateDefault(args);

// Authorization services
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();

// HTTP Client for calls to the host server
builder.Services.AddScoped(sp => 
    new HttpClient 
    { 
        BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) 
    });

// Client-side services (WASM implementations)
builder.Services.AddScoped<IEmployeeDataService, WasmEmployeeDataService>();

await builder.Build().RunAsync();

WASM Service vs Server Service:

// Services/WasmEmployeeDataService.cs — Client WASM side
// Must use HttpClient (no EF Core — no direct DB access)
public class WasmEmployeeDataService : IEmployeeDataService
{
    private readonly HttpClient _httpClient;
    
    public WasmEmployeeDataService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }
    
    public async Task<IEnumerable<Employee>> GetAllEmployeesAsync()
    {
        // HTTP API call to the server
        return await _httpClient.GetFromJsonAsync<IEnumerable<Employee>>(
            "api/employees") ?? Enumerable.Empty<Employee>();
    }
    
    public async Task<Employee?> GetEmployeeByIdAsync(int employeeId)
    {
        return await _httpClient.GetFromJsonAsync<Employee>($"api/employees/{employeeId}");
    }
    
    public async Task<Employee> CreateEmployeeAsync(Employee employee)
    {
        var response = await _httpClient.PostAsJsonAsync("api/employees", employee);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Employee>()
               ?? throw new InvalidOperationException("Invalid response");
    }
}

7.3 WASM Standalone — Full Structure

For a purely client-side WASM application:

// Program.cs — WASM Standalone App
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

// All services must work without a server
builder.Services.AddScoped(sp => new HttpClient 
{ 
    BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) 
});

// Authentication in standalone WASM:
builder.Services.AddOidcAuthentication(options =>
{
    builder.Configuration.Bind("Auth0", options.ProviderOptions);
});

await builder.Build().RunAsync();

8. JavaScript Interop

8.1 Calling JavaScript from Blazor

// Inject IJSRuntime into the component
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;

// ─── JS CALL WITHOUT RETURN ─────────────────────────────────────
await JSRuntime.InvokeVoidAsync("console.log", "Hello from Blazor!");
await JSRuntime.InvokeVoidAsync("showNotification", "Employee saved!", "success");

// ─── JS CALL WITH RETURN ────────────────────────────────────────
var windowWidth = await JSRuntime.InvokeAsync<int>("eval", "window.innerWidth");
var userAgent = await JSRuntime.InvokeAsync<string>("eval", "navigator.userAgent");

// ─── DOM ACCESS ─────────────────────────────────────────────────
// Get a reference to a DOM element
var element = await JSRuntime.InvokeAsync<IJSObjectReference>(
    "document.querySelector", "#myMap");

// ─── JS MODULE (recommended) ────────────────────────────────────
// Load an ES6 module
private IJSObjectReference? _mapModule;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        // Load the JS module
        _mapModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
            "import", "./js/map.js");
        
        // Initialize the map
        await _mapModule.InvokeVoidAsync("initMap", 
            "mapContainer", 
            Employee!.Latitude, 
            Employee.Longitude);
    }
}

8.2 Calling Blazor from JavaScript

// Expose a C# method to JavaScript with DotNetObjectReference
private DotNetObjectReference<EmployeeDetail>? _dotNetRef;

protected override void OnInitialized()
{
    // Create a reference to this component
    _dotNetRef = DotNetObjectReference.Create(this);
}

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        // Pass the reference to JavaScript
        await JSRuntime.InvokeVoidAsync("setupMapCallbacks", _dotNetRef);
    }
}

// Method callable from JavaScript with [JSInvokable]
[JSInvokable]
public void OnMapMarkerClicked(string markerId)
{
    Console.WriteLine($"Marker clicked: {markerId}");
    // Update Blazor UI
    StateHasChanged();
}

public async ValueTask DisposeAsync()
{
    // IMPORTANT: Release the reference to avoid memory leaks
    _dotNetRef?.Dispose();
    
    if (_mapModule is not null)
        await _mapModule.DisposeAsync();
}
// wwwroot/js/map.js (ES6 module)

// Initialize Google Maps or Leaflet
export function initMap(containerId, lat, lon) {
    const map = L.map(containerId).setView([lat, lon], 13);
    
    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '© OpenStreetMap contributors'
    }).addTo(map);
    
    const marker = L.marker([lat, lon]).addTo(map);
    return marker;
}

// Expose global functions for DotNetObjectReference
window.setupMapCallbacks = (dotNetRef) => {
    // When an HTML button is clicked, call C#
    document.getElementById('myButton').addEventListener('click', () => {
        dotNetRef.invokeMethodAsync('OnMapMarkerClicked', 'marker-1');
    });
};

// Toast notification (example)
window.showNotification = (message, type = 'info') => {
    // Use Bootstrap toast or an external library
    const toastEl = document.createElement('div');
    toastEl.className = `toast align-items-center text-bg-${type} border-0`;
    toastEl.innerHTML = `
        <div class="d-flex">
            <div class="toast-body">${message}</div>
            <button type="button" class="btn-close btn-close-white me-2 m-auto" 
                    data-bs-dismiss="toast"></button>
        </div>`;
    document.getElementById('toastContainer').appendChild(toastEl);
    new bootstrap.Toast(toastEl).show();
};

9. Authentication and Authorization

9.1 ASP.NET Core Identity with Blazor

// Program.cs — Identity configuration
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
    options.Password.RequiredLength = 8;
    options.SignIn.RequireConfirmedAccount = false; // Set true in production
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = IdentityConstants.ApplicationScheme;
    options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
})
.AddIdentityCookies();

builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<AuthenticationStateProvider, 
    IdentityRevalidatingAuthenticationStateProvider>();

9.2 Protecting Pages and Components

@* Protected page — requires authentication *@
@page "/employees"
@attribute [Authorize]

@* Protected page with role *@
@page "/admin"
@attribute [Authorize(Roles = "Admin")]

@* Conditional display *@
<AuthorizeView>
    <Authorized>
        <button @onclick="AddEmployee">Add employee</button>
    </Authorized>
    <NotAuthorized>
        <p>Sign in to add employees.</p>
    </NotAuthorized>
</AuthorizeView>

<AuthorizeView Roles="Admin">
    <Authorized>
        <button class="btn btn-danger" @onclick="DeleteEmployee">Delete</button>
    </Authorized>
</AuthorizeView>

10. Testing with bUnit

10.1 Introduction to bUnit

bUnit is the recommended unit testing framework for Blazor components. It allows testing Razor components in isolation, without a browser.

// Tests/EmployeeCardTests.cs
// NuGet Package: bunit
// dotnet add package bunit

public class EmployeeCardTests : TestContext
{
    [Fact]
    public void EmployeeCard_RendersEmployeeName()
    {
        // ─── ARRANGE ────────────────────────────────────────────
        var employee = new Employee
        {
            EmployeeId = 1,
            FirstName = "Sarah",
            LastName = "Johnson",
            Email = "sarah@bethany.com",
            HireDate = DateTime.Today.AddYears(-2)
        };
        
        // ─── ACT ─────────────────────────────────────────────────
        var cut = RenderComponent<EmployeeCard>(parameters => parameters
            .Add(p => p.Employee, employee));
        
        // ─── ASSERT ──────────────────────────────────────────────
        cut.Find("h5").MarkupMatches("<h5 class=\"card-title\">Sarah Johnson</h5>");
        cut.Find(".employee-email").TextContent.ShouldContain("sarah@bethany.com");
    }
    
    [Fact]
    public async Task EmployeeCard_QuickViewButton_InvokesCallback()
    {
        // ARRANGE
        var employee = new Employee { EmployeeId = 42, FirstName = "James", LastName = "Wilson" };
        int? clickedEmployeeId = null;
        
        // ACT
        var cut = RenderComponent<EmployeeCard>(parameters => parameters
            .Add(p => p.Employee, employee)
            .Add(p => p.OnQuickViewClicked, (int id) => { clickedEmployeeId = id; }));
        
        await cut.Find(".btn-outline-primary").ClickAsync(new MouseEventArgs());
        
        // ASSERT
        clickedEmployeeId.ShouldBe(42);
    }
    
    [Fact]
    public void EmployeeCard_WhenShowActions_False_HidesButtons()
    {
        // ARRANGE
        var employee = new Employee { EmployeeId = 1, FirstName = "A", LastName = "B" };
        
        // ACT
        var cut = RenderComponent<EmployeeCard>(parameters => parameters
            .Add(p => p.Employee, employee)
            .Add(p => p.ShowActions, false));
        
        // ASSERT
        cut.FindAll(".card-actions button").ShouldBeEmpty();
    }
}

10.2 Tests with Mocked Services

// Tests/EmployeeOverviewTests.cs
public class EmployeeOverviewTests : TestContext
{
    [Fact]
    public async Task EmployeeOverview_LoadsAndDisplaysEmployees()
    {
        // ─── ARRANGE ────────────────────────────────────────────
        var employees = new List<Employee>
        {
            new() { EmployeeId = 1, FirstName = "Sarah", LastName = "Johnson" },
            new() { EmployeeId = 2, FirstName = "James", LastName = "Wilson" }
        };
        
        // Mock service with Moq or NSubstitute
        var mockService = Substitute.For<IEmployeeDataService>();
        mockService.GetAllEmployeesAsync()
            .Returns(Task.FromResult<IEnumerable<Employee>>(employees));
        
        // Register the mock in bUnit's DI container
        Services.AddSingleton<IEmployeeDataService>(mockService);
        
        // ─── ACT ─────────────────────────────────────────────────
        var cut = RenderComponent<EmployeeOverview>();
        
        // Wait for async loading to complete
        await cut.WaitForStateAsync(() => 
            cut.FindAll(".employee-card").Count == 2);
        
        // ─── ASSERT ──────────────────────────────────────────────
        cut.FindAll(".employee-card").Count.ShouldBe(2);
        cut.Markup.ShouldContain("Sarah Johnson");
        cut.Markup.ShouldContain("James Wilson");
    }
    
    [Fact]
    public void EmployeeOverview_WhenLoading_ShowsSpinner()
    {
        // Service that doesn't respond immediately
        var mockService = Substitute.For<IEmployeeDataService>();
        var tcs = new TaskCompletionSource<IEnumerable<Employee>>();
        mockService.GetAllEmployeesAsync().Returns(tcs.Task);
        Services.AddSingleton<IEmployeeDataService>(mockService);
        
        var cut = RenderComponent<EmployeeOverview>();
        
        // During loading, the spinner should be visible
        cut.Find(".spinner-border").ShouldNotBeNull();
        cut.FindAll(".employee-card").ShouldBeEmpty();
    }
}

11. Advanced Components — Virtualize and QuickGrid

11.1 Virtualize — Performance with Large Lists

@* Without Virtualize: ALL divs rendered even with 1000+ *@
@foreach (var employee in AllEmployees)
{
    <EmployeeCard Employee="@employee" />  @* 1000 components in the DOM! *@
}

@* With Virtualize: only visible elements *@
<div style="height: 600px; overflow-y: auto;">
    <Virtualize Items="AllEmployees" Context="employee" ItemSize="120">
        @* Only renders elements in the visible window *@
        <EmployeeCard Employee="@employee" />
    </Virtualize>
</div>

On-demand loading (lazy loading):

@* Virtualize with ItemsProvider: loads data progressively *@
<Virtualize ItemsProvider="@LoadEmployeesAsync" Context="employee" ItemSize="120">
    <EmployeeCard Employee="@employee" />
</Virtualize>

@code {
    // Called automatically when the user scrolls
    private async ValueTask<ItemsProviderResult<Employee>> LoadEmployeesAsync(
        ItemsProviderRequest request)
    {
        // Load only the necessary items from DB
        var result = await EmployeeService.GetEmployeesPagedAsync(
            startIndex: request.StartIndex,
            count: request.Count);
        
        return new ItemsProviderResult<Employee>(
            result.Items,
            result.TotalCount);
    }
}

11.2 QuickGrid — Paginated Data Grid

@* Add package: Microsoft.AspNetCore.Components.QuickGrid *@
@using Microsoft.AspNetCore.Components.QuickGrid

@page "/employees/grid"

<PageTitle>Employee Grid</PageTitle>

@if (_employees is not null && _employees.Any())
{
    <QuickGrid Items="@_employees" Pagination="@_pagination" class="table table-striped">
        @* Configurable columns *@
        <PropertyColumn Property="@(e => e.EmployeeId)" 
                        Title="ID" 
                        Sortable="true"
                        IsDefaultSortColumn="true" />
        
        <TemplateColumn Title="Full Name">
            <div class="d-flex align-items-center gap-2">
                @if (!string.IsNullOrEmpty(context.ProfilePicture))
                {
                    <img src="@context.ProfilePicture" class="rounded-circle" 
                         width="32" height="32" alt="" />
                }
                <span>@context.FirstName @context.LastName</span>
            </div>
        </TemplateColumn>
        
        <PropertyColumn Property="@(e => e.Email)" Title="Email" Sortable="true" />
        
        <PropertyColumn Property="@(e => e.JobCategory!.JobCategoryName)" 
                        Title="Position" Sortable="true" />
        
        <PropertyColumn Property="@(e => e.HireDate)" 
                        Title="Hire Date" 
                        Sortable="true"
                        Format="MM/dd/yyyy" />
        
        <TemplateColumn Title="Status">
            @if (context.IsOnHoliday)
            {
                <span class="badge bg-warning">On Holiday</span>
            }
            else
            {
                <span class="badge bg-success">Active</span>
            }
        </TemplateColumn>
        
        <TemplateColumn Title="Actions">
            <a href="/employees/@context.EmployeeId" class="btn btn-sm btn-outline-primary">
                Details
            </a>
            <button class="btn btn-sm btn-outline-danger" 
                    @onclick="@(() => DeleteEmployee(context.EmployeeId))">
                Delete
            </button>
        </TemplateColumn>
    </QuickGrid>
    
    @* Pagination controls *@
    <div class="d-flex align-items-center gap-3 mt-3">
        <Paginator State="@_pagination" />
        <span class="text-muted">
            Total: @_employees.Count() employees
        </span>
    </div>
}
// EmployeeGrid.razor.cs
public partial class EmployeeGrid
{
    [Inject] private IEmployeeDataService EmployeeService { get; set; } = default!;
    
    private IQueryable<Employee>? _employees;
    private PaginationState _pagination = new() { ItemsPerPage = 10 };
    
    protected override async Task OnInitializedAsync()
    {
        var employees = await EmployeeService.GetAllEmployeesAsync();
        // QuickGrid requires IQueryable for client-side sort/pagination
        _employees = employees.AsQueryable();
    }
    
    private async Task DeleteEmployee(int employeeId)
    {
        if (await ConfirmDeleteAsync())
        {
            await EmployeeService.DeleteEmployeeAsync(employeeId);
            var employees = await EmployeeService.GetAllEmployeesAsync();
            _employees = employees.AsQueryable();
        }
    }
    
    private async Task<bool> ConfirmDeleteAsync()
    {
        return await JSRuntime.InvokeAsync<bool>(
            "confirm", "Are you sure you want to delete this employee?");
    }
}

12. Deployment on Azure

12.1 CI/CD Pipeline with GitHub Actions

# .github/workflows/deploy.yml
name: Build and Deploy to Azure

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Setup .NET
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: '8.0.x'
    
    - name: Restore dependencies
      run: dotnet restore
    
    - name: Build
      run: dotnet build --no-restore --configuration Release
    
    - name: Run tests
      run: dotnet test --no-build --configuration Release --verbosity normal
    
    - name: Publish
      run: dotnet publish --no-build --configuration Release --output ./publish
    
    - name: Deploy to Azure App Service
      uses: azure/webapps-deploy@v2
      with:
        app-name: 'bethany-pieshop-hrm'
        publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
        package: './publish'

12.2 Environment-Based Configuration

// Program.cs — Environment-based configuration
var builder = WebApplication.CreateBuilder(args);

if (builder.Environment.IsProduction())
{
    // In production: use Azure SQL
    builder.Services.AddDbContext<AppDbContext>(opts =>
        opts.UseSqlServer(builder.Configuration.GetConnectionString("AzureSQL")));
}
else
{
    // In development: use LocalDB or InMemory
    builder.Services.AddDbContext<AppDbContext>(opts =>
        opts.UseSqlServer(builder.Configuration.GetConnectionString("LocalDB")));
}

// Structured logging in production
if (builder.Environment.IsProduction())
{
    builder.Logging.ClearProviders();
    builder.Logging.AddApplicationInsights(); // Azure Application Insights
}

13. Best Practices

13.1 Quality Checklist

CategoryPracticeWhy
CodeUse code-behind (.razor.cs)Clear UI/logic separation
CodeInject via [Inject] or constructorDI = testability
PerformanceAsNoTracking() for readsLess EF Core memory
PerformanceVirtualize for large listsPartial rendering = performance
Security[Authorize] on sensitive pagesServer-side protection
SecurityValidate route parametersInjection prevention
UXStreamRendering for loadingImmediate feedback
MaintenanceErrorBoundary on critical componentsError isolation
TestingbUnit for Razor componentsQuality and regressions
Accessibilityrole, aria-label on interactive elementsWCAG 2.1

13.2 Common Anti-patterns to Avoid

@* ❌ BAD — Logic directly in the .razor file *@
@code {
    // 200 lines of logic here... use code-behind instead
}

@* ❌ BAD — No loading indicator *@
@foreach (var e in Employees!)  @* Crashes if Employees is null! *@

@* ✅ GOOD — Null check + loading state *@
@if (Employees is null)
{
    <p>Loading...</p>
}
else if (!Employees.Any())
{
    <p>No employees found.</p>
}
else
{
    @foreach (var e in Employees)
    {
        <EmployeeCard Employee="@e" />
    }
}
// ❌ BAD — DbContext injected directly into a Blazor Server component
// Lifecycle issue + not thread-safe
[Inject] private AppDbContext DbContext { get; set; } = default!;

// ✅ GOOD — DbContextFactory or intermediate service
[Inject] private IEmployeeDataService EmployeeService { get; set; } = default!;

// ❌ BAD — No error handling
protected override async Task OnInitializedAsync()
{
    Employees = await EmployeeService.GetAllEmployeesAsync();
}

// ✅ GOOD — Error handling + loading state
protected override async Task OnInitializedAsync()
{
    try
    {
        IsLoading = true;
        Employees = await EmployeeService.GetAllEmployeesAsync();
    }
    catch (Exception ex)
    {
        Error = $"Unable to load employees: {ex.Message}";
        Logger.LogError(ex, "Error loading employees");
    }
    finally
    {
        IsLoading = false;
    }
}

14. Practical Exercises

Exercise 1 — Add Employee Form (Intermediate, ~2h)

Objective: Implement a complete form for adding an employee.

Requirements:

  • Page /employees/add with EditForm
  • Validation via DataAnnotations
  • Dropdown list for job categories
  • Redirect to list after success
  • Error message on failure

Exercise 2 — State Management Between Components (Intermediate, ~1h30)

Objective: Share the selected employee between two unrelated components.

Requirements:

  • EmployeeList → selects an employee → stores in ApplicationState
  • EmployeeStats → displays stats for selected employee
  • Both components on the same page
  • Automatic update when user changes selection

Exercise 3 — JavaScript Interop (Advanced, ~2h)

Objective: Display a location map for each employee.

Requirements:

  • Use Leaflet.js (open source)
  • ES6 module in wwwroot/js/map.js
  • Display employee GPS coordinates
  • Clean up the map in DisposeAsync()

Exercise 4 — bUnit Tests (Advanced, ~2h)

Objective: Write unit tests for main components.

Tests to implement:

  • EmployeeCard — correct rendering
  • EmployeeCardOnQuickViewClicked callback
  • EmployeeOverview — spinner shown during loading
  • AddEmployee — validation prevents submission
  • AddEmployee — success navigates to list

15. Glossary

TermDefinition
ASP.NET Core Identity.NET framework for managing user authentication
bUnitUnit testing framework for Blazor components
CascadingParameterParameter automatically passed to all descendant components
CircuitSignalR session between a user and their Blazor Server instance
DataAnnotationsC# attributes defining validation rules on models
DbContextFactoryService creating thread-safe DbContext instances
DotNetObjectReferenceReference to a .NET object usable from JavaScript
DynamicComponentBlazor component loading a component by its type at runtime
EditFormBlazor component managing forms with validation
ErrorBoundaryComponent isolating exceptions at a subtree level
EventCallbackBlazor type for parent-child callbacks, thread-safe and async
IJSObjectReferenceReference to a JavaScript object returned by an ES6 module
IJSRuntimeInjection service for invoking JavaScript from Blazor
IQueryableInterface allowing deferred execution of a data query
ItemsProviderRequestPaginated data request for the Virtualize component
NSubstitute / MoqMocking libraries for creating fake objects in tests
PaginationStatePagination state shared with the QuickGrid component
QuickGridBlazor paginated and sortable data grid component
RenderFragmentDelegate representing a fragment of HTML content
Repository PatternPattern encapsulating data access logic
Scoped Service.NET service whose lifetime = one HTTP request (or SignalR circuit)
Singleton Service.NET service of which only one instance exists for the entire runtime
StateHasChangedBlazor method indicating that state has changed and a re-render is needed
VirtualizeBlazor component improving performance by only rendering visible items
WASMWebAssembly — .NET bytecode running in the browser
WebAssemblyHostBuilderBuilder for configuring a Blazor WASM application

Additional Resources


Table of Contents

  1. Blazor Overview
  2. Render Modes and Hosting Models
  3. Blazor Project Structure
  4. Razor Components
  5. Navigation and Routing
  6. Entity Framework Core and Data
  7. State Management
  8. Advanced Components
  9. Forms and Validation
  10. Blazor WebAssembly (WASM)
  11. JavaScript Interop
  12. Authentication and Authorization
  13. Testing with bUnit
  14. Deployment on Azure App Service
  15. .NET Aspire
  16. Summary and Key Points

1. Blazor Overview

Blazor = ASP.NET Core framework for building interactive web UIs in C# and HTML.

Key points:

  • No need for JavaScript for interactivity
  • Same language (C#) on both server AND client side
  • Reuses the .NET ecosystem (NuGet packages, VS debugging)
  • Part of ASP.NET Core

Capabilities:

  • Web apps (SPA, SSR, hybrid)
  • Desktop apps (via .NET MAUI Blazor Hybrid)
  • Mobile apps (iOS, Android via MAUI)

2. Render Modes and Hosting Models

The 4 Render Modes (.NET 8+):

ModeExecutionConnectionUsage
Static SSRServer → HTML → BrowserClassic Request/ResponseStatic pages, SEO
Interactive ServerServer (SignalR)SignalR circuit openRich interactivity, direct DB access
Interactive WebAssemblyBrowser (WASM)None after downloadOffline, CDN, API required
Interactive AutoServer → then WASMStarts Server, switches to WASMBest of both worlds

Set the render mode in a component:

@* Static server-side rendering (default) *@
@page "/about"
<h1>About</h1>

@* Interactive Server *@
@rendermode InteractiveServer

@* Interactive WebAssembly (must be in the Client project) *@
@rendermode InteractiveWebAssembly

@* Auto *@
@rendermode InteractiveAuto

In Program.cs for Interactive Server:

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()   // Add services
    .AddInteractiveWebAssemblyComponents(); // If WASM too

// ...

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode()    // Activate the mode
    .AddInteractiveWebAssemblyRenderMode();

3. Blazor Project Structure

Files generated by the Blazor Web App template:

MyBlazorApp/
├── Components/
│   ├── App.razor           ← Root component (HTML shell)
│   ├── Routes.razor        ← Router component
│   ├── Layout/
│   │   ├── MainLayout.razor    ← Main layout
│   │   └── NavMenu.razor       ← Navigation menu
│   └── Pages/
│       ├── Home.razor          ← Home page
│       ├── Counter.razor       ← Interactive example
│       └── Weather.razor       ← SSR with fetch example
├── wwwroot/                ← Static files (CSS, JS, images)
│   ├── app.css
│   └── lib/bootstrap/
├── Program.cs              ← Entry point, DI, middleware
└── appsettings.json

App.razor (root):

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="/" />
    <link rel="stylesheet" href="app.css" />
    <HeadOutlet />
</head>
<body>
    <Routes />
    <script src="_framework/blazor.web.js"></script>
</body>
</html>

Routes.razor:

<Router AppAssembly="typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
        <FocusOnNavigate RouteData="routeData" Selector="h1" />
    </Found>
</Router>

4. Razor Components

4.1 Component Anatomy

.razor file (UI + inline code):

@page "/employees"

<h1>Employees</h1>

@if (employees == null)
{
    <p>Loading...</p>
}
else
{
    <ul>
        @foreach (var emp in employees)
        {
            <li>@emp.FirstName @emp.LastName</li>
        }
    </ul>
}

@code {
    private List<Employee>? employees;

    protected override async Task OnInitializedAsync()
    {
        employees = await EmployeeService.GetAllAsync();
    }
}

.razor.cs file (code-behind, recommended):

// EmployeeOverview.razor.cs
public partial class EmployeeOverview
{
    [Inject]
    public IEmployeeDataService EmployeeDataService { get; set; } = default!;

    private List<Employee>? employees;

    protected override async Task OnInitializedAsync()
    {
        employees = await EmployeeDataService.GetAllEmployeesAsync();
    }
}

Lifecycle hooks:

// Synchronous
protected override void OnInitialized() { /* single call */ }
protected override void OnParametersSet() { /* parameters received */ }
protected override void OnAfterRender(bool firstRender) { /* after render */ }

// Asynchronous
protected override async Task OnInitializedAsync() { /* recommended */ }
protected override async Task OnParametersSetAsync() { }
protected override async Task OnAfterRenderAsync(bool firstRender) { }

4.2 Parameters and Events

Define and use a parameter:

@* EmployeeCard.razor *@
<div class="card">
    <h3>@Employee.FirstName @Employee.LastName</h3>
    <p>@Employee.Department</p>
    <button @onclick="OnSelectClicked">Select</button>
</div>

@code {
    [Parameter]
    public Employee Employee { get; set; } = default!;

    [Parameter]
    public EventCallback<Employee> OnEmployeeSelected { get; set; }

    private async Task OnSelectClicked()
    {
        await OnEmployeeSelected.InvokeAsync(Employee);
    }
}

Parent using the child component:

@* EmployeeOverview.razor *@
@foreach (var emp in employees)
{
    <EmployeeCard 
        Employee="emp" 
        OnEmployeeSelected="HandleEmployeeSelected" />
}

@code {
    private void HandleEmployeeSelected(Employee employee)
    {
        selectedEmployee = employee;
    }
}

Child Content (slot):

@* Panel.razor *@
<div class="panel">
    @ChildContent
</div>

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

@* Usage *@
<Panel>
    <h2>Content inside panel</h2>
</Panel>

4.3 Streaming Rendering

Streaming Rendering allows displaying content immediately while data is loading.

@page "/employees"
@attribute [StreamRendering(true)]

@if (employees == null)
{
    <p>⏳ Loading employees...</p>
}
else
{
    @foreach (var emp in employees)
    {
        <div>@emp.FirstName @emp.LastName</div>
    }
}

@code {
    private IEnumerable<Employee>? employees;

    protected override async Task OnInitializedAsync()
    {
        // Simulate delay (database)
        await Task.Delay(2000);
        employees = await EmployeeService.GetAllAsync();
    }
}

Flow: The user immediately sees “Loading…” then data appears when available.


5. Navigation and Routing

Define a route:

@page "/employee/{employeeId:int}"

<h2>Employee: @EmployeeId</h2>

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

Router with type constraints:

ConstraintDescription
:intInteger
:guidGUID
:boolBoolean
:datetimeDate
:decimalDecimal

Programmatic navigation:

[Inject]
public NavigationManager NavigationManager { get; set; } = default!;

private void GoToDetails(int employeeId)
{
    NavigationManager.NavigateTo($"/employee/{employeeId}");
}

Links with NavLink:

<NavLink href="/employees" class="nav-link" Match="NavLinkMatch.All">
    Employees
</NavLink>

6. Entity Framework Core and Data

6.1 EF Core Configuration

NuGet packages:

<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.x" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.x" />
<!-- For dev/test in memory: -->
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.x" />

DbContext:

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Employee> Employees { get; set; }
    public DbSet<Department> Departments { get; set; }
}

Register in Program.cs:

// InMemory (dev/test)
builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseInMemoryDatabase("BethanysPieShopHRM"));

// SQL Server (prod)
builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

Seed data:

// After app.Build(), before app.Run()
using var scope = app.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (!context.Employees.Any())
{
    context.Employees.AddRange(
        new Employee { FirstName = "John", LastName = "Doe", Department = "IT" },
        new Employee { FirstName = "Jane", LastName = "Smith", Department = "HR" }
    );
    context.SaveChanges();
}

6.2 Repository Pattern and DI

Repository Interface:

public interface IEmployeeRepository
{
    Task<IEnumerable<Employee>> GetAllEmployeesAsync();
    Task<Employee?> GetEmployeeByIdAsync(int id);
    Task<Employee> AddEmployeeAsync(Employee employee);
    Task UpdateEmployeeAsync(Employee employee);
    Task DeleteEmployeeAsync(int id);
}

Implementation:

public class EmployeeRepository : IEmployeeRepository
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public EmployeeRepository(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public async Task<IEnumerable<Employee>> GetAllEmployeesAsync()
    {
        using var context = await _contextFactory.CreateDbContextAsync();
        return await context.Employees.ToListAsync();
    }
}

Service Layer:

public class EmployeeDataService : IEmployeeDataService
{
    private readonly IEmployeeRepository _repository;

    public EmployeeDataService(IEmployeeRepository repository)
    {
        _repository = repository;
    }

    public async Task<IEnumerable<Employee>> GetAllEmployeesAsync()
        => await _repository.GetAllEmployeesAsync();
}

Register in Program.cs:

builder.Services.AddScoped<IEmployeeRepository, EmployeeRepository>();
builder.Services.AddScoped<IEmployeeDataService, EmployeeDataService>();

7. State Management

Problem: By default, each navigation recreates components → state lost.

Solution — Application State Service:

// ApplicationState.cs
public class ApplicationState
{
    public Employee? SelectedEmployee { get; set; }
    public bool IsOnHoliday { get; set; }
    
    public event Action? OnChange;
    
    public void SetSelectedEmployee(Employee employee)
    {
        SelectedEmployee = employee;
        NotifyStateChanged();
    }
    
    private void NotifyStateChanged() => OnChange?.Invoke();
}

Register as Scoped (shared within a SignalR session):

builder.Services.AddScoped<ApplicationState>();

Use in a component:

[Inject]
public ApplicationState AppState { get; set; } = default!;

protected override void OnInitialized()
{
    AppState.OnChange += StateHasChanged;
}

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

8. Advanced Components

8.1 Virtualize and QuickGrid

Virtualize — efficient rendering of long lists:

<div style="height:400px; overflow-y:auto;">
    <Virtualize Items="allEmployees" Context="emp">
        <ItemContent>
            <EmployeeRow Employee="emp" />
        </ItemContent>
        <Placeholder>
            <p>Loading...</p>
        </Placeholder>
    </Virtualize>
</div>

QuickGrid — paginated and filterable grid:

<!-- NuGet: Microsoft.AspNetCore.Components.QuickGrid -->
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid" Version="8.x" />
@using Microsoft.AspNetCore.Components.QuickGrid

<QuickGrid Items="employees.AsQueryable()" Pagination="pagination">
    <PropertyColumn Property="emp => emp.FirstName" Sortable="true" />
    <PropertyColumn Property="emp => emp.LastName" Sortable="true" />
    <PropertyColumn Property="emp => emp.Department" />
    <TemplateColumn>
        <button @onclick="() => SelectEmployee(context)">Select</button>
    </TemplateColumn>
</QuickGrid>

<Paginator State="pagination" />

@code {
    PaginationState pagination = new() { ItemsPerPage = 10 };
}

9. Forms and Validation

EditForm with validation:

<EditForm Model="newEmployee" OnValidSubmit="HandleValidSubmit" FormName="AddEmployee">
    <DataAnnotationsValidator />
    <ValidationSummary />
    
    <div class="form-group">
        <label>First Name</label>
        <InputText @bind-Value="newEmployee.FirstName" class="form-control" />
        <ValidationMessage For="@(() => newEmployee.FirstName)" />
    </div>
    
    <div class="form-group">
        <label>Department</label>
        <InputSelect @bind-Value="newEmployee.DepartmentId" class="form-control">
            @foreach (var dept in departments)
            {
                <option value="@dept.Id">@dept.Name</option>
            }
        </InputSelect>
    </div>
    
    <div class="form-group">
        <label>Start Date</label>
        <InputDate @bind-Value="newEmployee.StartDate" class="form-control" />
    </div>
    
    <button type="submit" class="btn btn-primary">Save</button>
</EditForm>

@code {
    [SupplyParameterFromForm]
    private Employee newEmployee { get; set; } = new();
    
    private List<Department> departments = new();
    
    protected override async Task OnInitializedAsync()
    {
        departments = await DepartmentService.GetAllAsync();
    }
    
    private async Task HandleValidSubmit()
    {
        await EmployeeService.AddEmployeeAsync(newEmployee);
        NavigationManager.NavigateTo("/employees");
    }
}

Data Annotations on the model:

public class Employee
{
    public int Id { get; set; }
    
    [Required(ErrorMessage = "First name is required")]
    [StringLength(50, ErrorMessage = "Max 50 characters")]
    public string FirstName { get; set; } = string.Empty;
    
    [Required]
    [EmailAddress]
    public string Email { get; set; } = string.Empty;
    
    [Range(18, 67)]
    public int Age { get; set; }
}

InputFile (file upload):

<InputFile OnChange="OnFileSelected" accept=".jpg,.png" />

@code {
    private async Task OnFileSelected(InputFileChangeEventArgs e)
    {
        var file = e.File;
        if (file.Size > 2 * 1024 * 1024) // 2 MB max
        {
            errorMessage = "File is too large";
            return;
        }
        
        using var stream = file.OpenReadStream();
        // Process the file
    }
}

10. Blazor WebAssembly (WASM)

WASM Architecture:

  • Server project: ASP.NET Core + Minimal APIs
  • Client project: Blazor WASM (downloaded in the browser)

Minimal API in the Server project:

// Program.cs (server)
app.MapGet("/api/employees", async (IEmployeeRepository repo) =>
    await repo.GetAllEmployeesAsync());

app.MapGet("/api/employees/{id}", async (int id, IEmployeeRepository repo) =>
    await repo.GetEmployeeByIdAsync(id) is Employee emp
        ? Results.Ok(emp)
        : Results.NotFound());

app.MapPost("/api/employees", async (Employee emp, IEmployeeRepository repo) =>
{
    var created = await repo.AddEmployeeAsync(emp);
    return Results.Created($"/api/employees/{created.Id}", created);
});

HttpClient in the Client project:

// Program.cs (client)
builder.Services.AddScoped(sp => 
    new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<IEmployeeDataService, EmployeeDataServiceClient>();

Client service using HttpClient:

public class EmployeeDataServiceClient : IEmployeeDataService
{
    private readonly HttpClient _httpClient;

    public EmployeeDataServiceClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<IEnumerable<Employee>> GetAllEmployeesAsync()
        => await _httpClient.GetFromJsonAsync<IEnumerable<Employee>>("api/employees") 
           ?? [];
}

LocalStorage with Blazored.LocalStorage:

<PackageReference Include="Blazored.LocalStorage" Version="4.x" />
// Program.cs
builder.Services.AddBlazoredLocalStorage();

// In a component
[Inject]
public ILocalStorageService LocalStorage { get; set; } = default!;

await LocalStorage.SetItemAsync("key", value);
var stored = await LocalStorage.GetItemAsync<T>("key");

11. JavaScript Interop

Call JavaScript from C#:

[Inject]
public IJSRuntime JSRuntime { get; set; } = default!;

// Async call (returns a value)
var result = await JSRuntime.InvokeAsync<string>("myJsFunction", arg1, arg2);

// Call without return
await JSRuntime.InvokeVoidAsync("console.log", "Hello from Blazor!");

// Show JS alert
await JSRuntime.InvokeVoidAsync("alert", "Saved successfully!");

Call C# from JavaScript:

// In JavaScript
const dotNetObj = DotNet.createBlazorObject('MyApp.Components.MyComponent');
const result = await dotNetObj.invokeMethodAsync('MyMethod', arg1);
// In the C# component
[JSInvokable]
public static async Task<string> MyMethod(string input)
{
    return $"Received: {input}";
}

Razor Class Library (RCL) — share components:

# Create an RCL
dotnet new razorclasslib -n MyComponentLibrary

# Reference in the main project
dotnet add reference ../MyComponentLibrary

12. Authentication and Authorization

Configuration in Program.cs:

builder.Services.AddAuthentication(options => { ... })
    .AddCookie();

builder.Services.AddCascadingAuthenticationState();

// In AddRazorComponents
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

Routes.razor with auth:

<Router AppAssembly="typeof(Program).Assembly">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="routeData" 
                           DefaultLayout="typeof(Layout.MainLayout)">
            <NotAuthorized>
                <p>You are not authorized to view this page.</p>
            </NotAuthorized>
        </AuthorizeRouteView>
    </Found>
</Router>

AuthorizeView in a component:

<AuthorizeView>
    <Authorized>
        <p>Hello, @context.User.Identity?.Name!</p>
        <button @onclick="Logout">Sign out</button>
    </Authorized>
    <NotAuthorized>
        <a href="/login">Sign in</a>
    </NotAuthorized>
</AuthorizeView>

@* With roles *@
<AuthorizeView Roles="Admin,Manager">
    <p>Reserved for admins and managers</p>
</AuthorizeView>

[Authorize] on a page:

@page "/admin"
@attribute [Authorize(Roles = "Admin")]

<h1>Admin Panel</h1>

13. Testing with bUnit

Create a bUnit project:

dotnet new bunit --framework xunit -n MyApp.Tests

Simple test:

using Bunit;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

public class EmployeeOverviewTests : TestContext
{
    [Fact]
    public void EmployeeOverview_ShowsEmployees_WhenLoaded()
    {
        // Arrange - mock the service
        this.Services.AddScoped<IEmployeeDataService, MockEmployeeDataService>();
        
        // Act - render the component
        var cut = RenderComponent<EmployeeOverview>();
        
        // Assert - check the rendered HTML
        cut.MarkupMatches(@"<ul>
            <li>John Doe</li>
            <li>Jane Smith</li>
        </ul>");
    }
    
    [Fact]
    public void EmployeeCard_ShowsName()
    {
        var emp = new Employee { FirstName = "John", LastName = "Doe" };
        
        var cut = RenderComponent<EmployeeCard>(p => p
            .Add(c => c.Employee, emp));
        
        Assert.Contains("John Doe", cut.Markup);
    }
}

// Mock service
public class MockEmployeeDataService : IEmployeeDataService
{
    public Task<IEnumerable<Employee>> GetAllEmployeesAsync() =>
        Task.FromResult<IEnumerable<Employee>>(new[]
        {
            new Employee { FirstName = "John", LastName = "Doe" },
            new Employee { FirstName = "Jane", LastName = "Smith" }
        });
}

14. Deployment on Azure App Service

Via Visual Studio:

Right-click on project → Publish
→ Target: Azure → Azure App Service (Windows or Linux)
→ Create a new App Service OR select existing
→ Publish

App Service Plan:

  • The App Service Plan = underlying infrastructure (CPU, RAM)
  • Scale up: move to a more powerful plan
  • Scale out: increase the number of instances

Available tiers:

TierUsageFeatures
Free/SharedDev, test60 min CPU/day
Basic (B1-B3)Dev, testManual scale
Standard (S1-S3)ProductionAuto-scale
Premium (P1v3+)High performanceVNet integration

Via Azure CLI:

# Create an App Service Plan
az appservice plan create \
  --name my-plan \
  --resource-group my-rg \
  --sku S1

# Create the App Service
az webapp create \
  --name my-blazor-app \
  --resource-group my-rg \
  --plan my-plan \
  --runtime "DOTNET|8.0"

# Deploy
dotnet publish -c Release -o ./publish
cd publish && zip -r ../app.zip .
az webapp deploy \
  --resource-group my-rg \
  --name my-blazor-app \
  --src-path app.zip

15. .NET Aspire

.NET Aspire solves the orchestration of distributed applications in local development.

Structure of an Aspire project:

MyApp.AppHost/        ← Orchestrator (launches all services)
MyApp.ServiceDefaults/ ← Common configurations (telemetry, health)
MyApp.Api/            ← Backend API
MyApp.Web/            ← Blazor frontend
MyApp.Worker/         ← Background service

AppHost/Program.cs:

var builder = DistributedApplication.CreateBuilder(args);

var api = builder.AddProject<Projects.MyApp_Api>("api");
var web = builder.AddProject<Projects.MyApp_Web>("web")
    .WithReference(api);

builder.Build().Run();

Benefits:

  • Service discovery: services find each other automatically
  • Dashboard: visualize all services, logs, metrics
  • Resilience: retry policies configured by default
  • OpenTelemetry: integrated distributed traces

16. Summary and Key Points

ConceptDescription
BlazorASP.NET Core framework for interactive UIs in C#
Static SSRServer-side HTML rendering (request/response)
Interactive ServerC# runs on the server via SignalR
Interactive WASMC# compiled and executed in the browser
InteractiveAutoStarts as Server, automatically switches to WASM
@pageDirective to make a component navigable
@rendermodeSpecify the render mode of a component
[Parameter]Property received from the parent component
EventCallbackTyped event sent to the parent
StreamRenderingDisplay content immediately + progressive loading
EditFormForm with data annotations validation
IDbContextFactoryCreate DbContext instances in Blazor Server
bUnitTesting framework for Blazor components
VirtualizeRender only visible items in a long list
QuickGridPaginated and sortable grid for Blazor

Search Terms

blazor · fundamentals · web · ui · c# · .net · development · components · javascript · wasm · app · bunit · core · management · state · configuration · data · interop · modes · quickgrid · render · virtualize · authentication · authorization

Interested in this course?

Contact us to book it or get a custom training plan for your team.