Intermediate

Building Web Applications with Blazor

Real-world Blazor — advanced components, EF Core, state, Virtualize/QuickGrid, WASM and auth.

Prerequisites: Blazor Foundations Demo Application: Bethany’s Pie Shop HRM (continuation of the Foundations course) Level: Intermediate to Advanced | Estimated duration: ~5h


Table of Contents

  1. Advanced Components
  2. Entity Framework Core with SQLite
  3. State Management
  4. Virtualize and QuickGrid Components
  5. Forms and Validation
  6. Blazor WebAssembly in an Existing Project
  7. JavaScript Interop and Razor Class Libraries
  8. Authentication and Authorization
  9. Production Best Practices
  10. Practical Exercises
  11. Glossary

1. Advanced Components

1.1 Parent ↔ Child Communication

flowchart TD
    Parent[EmployeeOverview\nParent Component] -->|"Employee parameter\n[Parameter]"| Card[EmployeeCard\nChild Component]
    Card -->|"EventCallback\nOnQuickViewClicked"| Parent
    Parent -->|"Employee + IsVisible"| Popup[QuickViewPopup\nChild Component]
    
    subgraph DataFlow["Data Flow Rules"]
        R1["Parent → Child: [Parameter]"]
        R2["Child → Parent: EventCallback"]
        R3["⚠️ Child never modifies parent state directly"]
    end

EmployeeCard — Child component with callback:

// Components/EmployeeCard.razor.cs
public partial class EmployeeCard
{
    [Parameter, EditorRequired]
    public Employee Employee { get; set; } = default!;
    
    [Parameter]
    public EventCallback<int> OnQuickViewClicked { get; set; }
    
    [Parameter]
    public EventCallback<Employee> OnEmployeeSelected { get; set; }
    
    private async Task HandleQuickView()
        => await OnQuickViewClicked.InvokeAsync(Employee.EmployeeId);
    
    private async Task HandleSelect()
        => await OnEmployeeSelected.InvokeAsync(Employee);
}
@* Components/EmployeeCard.razor *@
<div class="card shadow-sm employee-card">
    <div class="card-body">
        @if (!string.IsNullOrEmpty(Employee.ProfilePictureUrl))
        {
            <img src="@Employee.ProfilePictureUrl" class="employee-avatar" 
                 alt="@Employee.FullName" />
        }
        else
        {
            <div class="employee-avatar-placeholder">
                <span class="bi bi-person-fill fs-2"></span>
            </div>
        }
        <h5 class="card-title mt-2">@Employee.FullName</h5>
        <p class="card-text text-muted small">@Employee.Email</p>
        <p class="card-text">
            <span class="badge bg-secondary">@Employee.JobCategory?.JobCategoryName</span>
            @if (Employee.IsOnHoliday)
            {
                <span class="badge bg-warning ms-1">On Holiday</span>
            }
        </p>
        <div class="card-actions mt-2 d-flex gap-2">
            <button class="btn btn-sm btn-outline-primary" @onclick="HandleQuickView">
                Quick View
            </button>
            <button class="btn btn-sm btn-primary" @onclick="HandleSelect">
                Details
            </button>
        </div>
    </div>
</div>

EmployeeOverview — Parent component handling callbacks:

@* Components/Pages/EmployeeOverview.razor *@
@page "/employees"
@attribute [StreamRendering(true)]

<h1>@Title</h1>

@if (Employees is null)
{
    <div class="row g-3">
        @for (int i = 0; i < 6; i++)
        {
            <div class="col-md-4">
                <div class="card placeholder-glow">
                    <div class="card-body">
                        <h5 class="placeholder col-6"></h5>
                        <p class="placeholder col-8"></p>
                    </div>
                </div>
            </div>
        }
    </div>
}
else
{
    <div class="row g-3">
        @foreach (var employee in Employees)
        {
            <div class="col-md-4">
                <ErrorBoundary>
                    <ChildContent>
                        <EmployeeCard Employee="@employee"
                                     OnQuickViewClicked="@ShowQuickView"
                                     OnEmployeeSelected="@NavigateToDetail" />
                    </ChildContent>
                    <ErrorContent>
                        <div class="alert alert-warning">
                            Invalid employee (ID: @employee.EmployeeId)
                        </div>
                    </ErrorContent>
                </ErrorBoundary>
            </div>
        }
    </div>
    
    @* Quick View Popup *@
    @if (_showQuickView && _selectedEmployee is not null)
    {
        <QuickViewPopup Employee="@_selectedEmployee" 
                        OnClose="@CloseQuickView" />
    }
}

1.2 RenderFragment — Customizable Child Content

// Components/ProfilePicture.razor.cs
public partial class ProfilePicture
{
    // ChildContent = Blazor convention for content between tags
    [Parameter]
    public RenderFragment? ChildContent { get; set; }
    
    [Parameter]
    public string? AltText { get; set; }
    
    [Parameter]
    public int SizePixels { get; set; } = 80;
}
@* Components/ProfilePicture.razor *@
<div class="profile-picture-container" style="width: @(SizePixels)px; height: @(SizePixels)px;">
    @* ChildContent is injected here *@
    @ChildContent
</div>

@* Usage from EmployeeDetail *@
<ProfilePicture AltText="@Employee.FullName" SizePixels="120">
    @* Content between the tags → ChildContent *@
    <img src="@Employee.ProfilePictureUrl" 
         alt="@Employee.FullName"
         class="rounded-circle w-100 h-100 object-fit-cover" />
</ProfilePicture>

1.3 DynamicComponent — Dynamic Dashboard

// Components/Pages/Home.razor.cs
public partial class Home
{
    private readonly List<(Type ComponentType, IDictionary<string, object>? Params)> _widgets 
        = new()
        {
            (typeof(EmployeeCountWidget), null),
            (typeof(InboxWidget), new Dictionary<string, object> { { "MaxItems", 5 } }),
            (typeof(QuickStatsWidget), null)
        };
}
@* Components/Pages/Home.razor *@
@page "/"
<h1>Dashboard</h1>
<div class="row g-3">
    @foreach (var (componentType, parameters) in _widgets)
    {
        <div class="col-md-4">
            <DynamicComponent Type="@componentType" Parameters="@parameters" />
        </div>
    }
</div>

2. Entity Framework Core with SQLite

2.1 Migrating from InMemory to SQLite

// Before (InMemory):
builder.Services.AddDbContextFactory<AppDbContext>(opts =>
    opts.UseInMemoryDatabase("BethanyHRM"));

// After (SQLite):
var connectionString = builder.Configuration.GetConnectionString("Default")
    ?? "Data Source=bethany.db";

builder.Services.AddDbContextFactory<AppDbContext>(opts =>
    opts.UseSqlite(connectionString));

appsettings.json:

{
  "ConnectionStrings": {
    "Default": "Data Source=bethany.db"
  }
}

Required NuGet packages:

<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.*" />

2.2 EF Core Migrations

# Package Manager Console (Visual Studio)

# 1. Create the initial migration
Add-Migration InitialMigration
# → Creates Migrations/YYYYMMDD_InitialMigration.cs

# 2. Apply migrations (creates the .db file)
Update-Database
# → Creates bethany.db with tables

# 3. After modifying the model (e.g., add TimeRegistration)
Add-Migration AddTimeRegistrations
Update-Database

# 4. On error
Remove-Migration  # Undo the last unapplied migration

Generated migration file:

// Migrations/20240115_InitialMigration.cs
public partial class InitialMigration : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateTable(
            name: "Countries",
            columns: table => new
            {
                CountryId = table.Column<int>(type: "INTEGER", nullable: false)
                    .Annotation("Sqlite:Autoincrement", true),
                CountryName = table.Column<string>(type: "TEXT", nullable: false),
                CountryCode = table.Column<string>(type: "TEXT", nullable: true)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_Countries", x => x.CountryId);
            });
        
        migrationBuilder.CreateTable(
            name: "Employees",
            columns: table => new
            {
                EmployeeId = table.Column<int>(type: "INTEGER", nullable: false)
                    .Annotation("Sqlite:Autoincrement", true),
                FirstName = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
                LastName = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
                Email = table.Column<string>(type: "TEXT", nullable: false),
                HireDate = table.Column<DateTime>(type: "TEXT", nullable: false),
                IsOnHoliday = table.Column<bool>(type: "INTEGER", nullable: false),
                CountryId = table.Column<int>(type: "INTEGER", nullable: false),
                JobCategoryId = table.Column<int>(type: "INTEGER", nullable: false)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_Employees", x => x.EmployeeId);
                table.ForeignKey(
                    name: "FK_Employees_Countries_CountryId",
                    column: x => x.CountryId,
                    principalTable: "Countries",
                    principalColumn: "CountryId",
                    onDelete: ReferentialAction.Restrict);
            });
    }
    
    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropTable(name: "Employees");
        migrationBuilder.DropTable(name: "Countries");
    }
}

2.3 Seed Data with HasData

// Data/AppDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Relationship configuration
    modelBuilder.Entity<Employee>()
        .HasOne(e => e.Country)
        .WithMany()
        .HasForeignKey(e => e.CountryId)
        .OnDelete(DeleteBehavior.Restrict);
    
    // Reference data (immutable seed)
    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" },
        new Country { CountryId = 4, CountryName = "Germany", CountryCode = "DE" }
    );
    
    modelBuilder.Entity<JobCategory>().HasData(
        new JobCategory { JobCategoryId = 1, JobCategoryName = "Developer" },
        new JobCategory { JobCategoryId = 2, JobCategoryName = "Project Manager" },
        new JobCategory { JobCategoryId = 3, JobCategoryName = "UX Designer" },
        new JobCategory { JobCategoryId = 4, JobCategoryName = "DevOps" }
    );
    
    modelBuilder.Entity<Employee>().HasData(
        new Employee
        {
            EmployeeId = 1,
            FirstName = "Sarah",
            LastName = "Johnson",
            Email = "sarah.johnson@bethany.com",
            HireDate = new DateTime(2020, 3, 15),
            IsOnHoliday = false,
            CountryId = 1,
            JobCategoryId = 1
        },
        new Employee
        {
            EmployeeId = 2,
            FirstName = "James",
            LastName = "Mitchell",
            Email = "j.mitchell@bethany.com",
            HireDate = new DateTime(2019, 7, 1),
            IsOnHoliday = true,
            CountryId = 2,
            JobCategoryId = 2
        }
    );
}

2.4 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<IEnumerable<TimeRegistration>> GetTimeRegistrationsAsync(
        int employeeId, int startIndex, int count);
    Task<int> GetTimeRegistrationsCountAsync(int employeeId);
}

// 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)
            .AsNoTracking()
            .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)
            .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<IEnumerable<TimeRegistration>> GetTimeRegistrationsAsync(
        int employeeId, int startIndex, int count)
    {
        await using var ctx = await _contextFactory.CreateDbContextAsync();
        return await ctx.TimeRegistrations
            .Where(t => t.EmployeeId == employeeId)
            .OrderBy(t => t.StartTime)
            .Skip(startIndex)
            .Take(count)
            .AsNoTracking()
            .ToListAsync();
    }
    
    public async Task<int> GetTimeRegistrationsCountAsync(int employeeId)
    {
        await using var ctx = await _contextFactory.CreateDbContextAsync();
        return await ctx.TimeRegistrations
            .CountAsync(t => t.EmployeeId == employeeId);
    }
}

3. State Management

3.1 ApplicationState — Shared Service

flowchart TB
    subgraph Problem["Problem — Without state management"]
        C1[Inbox Widget\nshows 5 messages] -.->|"No connection"| C2[Stats Widget\ndoesn't know how many]
    end
    
    subgraph Solution["Solution — With ApplicationState"]
        AS[ApplicationState\nScoped Service] -->|"OnChange event"| W1[Inbox Widget]
        AS -->|"OnChange event"| W2[Stats Widget]
        AS -->|"OnChange event"| W3[NavMenu]
        note["All components subscribe\nand update automatically"]
    end
// State/ApplicationState.cs
public class ApplicationState
{
    // ─── STATE ────────────────────────────────────────────────────
    public int NumberOfMessages { get; private set; }
    public Employee? SelectedEmployee { get; private set; }
    public string SearchQuery { get; private set; } = string.Empty;
    
    // ─── EVENT ────────────────────────────────────────────────────
    // Components subscribe here to be notified
    public event Action? OnChange;
    
    // ─── MUTATIONS ────────────────────────────────────────────────
    public void SetMessageCount(int count)
    {
        NumberOfMessages = count;
        NotifyStateChanged();
    }
    
    public void SelectEmployee(Employee employee)
    {
        SelectedEmployee = employee;
        NotifyStateChanged();
    }
    
    public void UpdateSearch(string query)
    {
        SearchQuery = query;
        NotifyStateChanged();
    }
    
    private void NotifyStateChanged() => OnChange?.Invoke();
}

Registration and usage:

// Program.cs — Shared state (Scoped = one instance per SignalR circuit)
builder.Services.AddScoped<ApplicationState>();
@* Component that consumes and modifies state *@
@rendermode InteractiveServer
@inject ApplicationState AppState
@implements IDisposable

<div class="inbox-widget card">
    <div class="card-header">
        <h5>Inbox</h5>
    </div>
    <div class="card-body">
        @if (AppState.NumberOfMessages > 0)
        {
            <p class="text-danger fw-bold">@AppState.NumberOfMessages unread message(s)</p>
        }
        else
        {
            <p class="text-muted">No new messages</p>
        }
    </div>
</div>

@code {
    protected override void OnInitialized()
    {
        AppState.OnChange += StateHasChanged;  // Subscribe
        _ = LoadMessagesAsync();
    }
    
    private async Task LoadMessagesAsync()
    {
        // Simulate loading
        await Task.Delay(500);
        AppState.SetMessageCount(Random.Shared.Next(0, 10));
    }
    
    public void Dispose()
    {
        AppState.OnChange -= StateHasChanged;  // Unsubscribe!
    }
}

4. Virtualize and QuickGrid Components

4.1 Virtualize — For Large Lists

@* Basic virtualization with Items *@
<div style="height: 500px; overflow-y: auto;">
    <Virtualize Items="@AllEmployees" Context="employee" ItemSize="100">
        <EmployeeCard Employee="@employee" />
    </Virtualize>
</div>

@* Virtualization with on-demand loading (ItemsProvider) *@
<div style="height: 500px; overflow-y: auto;">
    <Virtualize ItemsProvider="@LoadTimeRegistrationsAsync" 
                Context="registration" 
                ItemSize="50">
        <div class="time-reg-row d-flex justify-content-between border-bottom py-2">
            <span>@registration.StartTime.ToString("MM/dd HH:mm")</span>
            <span>@registration.EndTime.ToString("HH:mm")</span>
            <span>@registration.Description</span>
        </div>
    </Virtualize>
</div>

@code {
    // ItemsProvider is called automatically when the user scrolls
    private async ValueTask<ItemsProviderResult<TimeRegistration>> LoadTimeRegistrationsAsync(
        ItemsProviderRequest request)
    {
        var totalCount = await TimeRegService.GetCountAsync(EmployeeId);
        var items = await TimeRegService.GetAsync(
            EmployeeId,
            startIndex: request.StartIndex,
            count: request.Count,
            cancellationToken: request.CancellationToken);
        
        return new ItemsProviderResult<TimeRegistration>(items, totalCount);
    }
}

4.2 QuickGrid — Full Data Grid

@using Microsoft.AspNetCore.Components.QuickGrid

@* Package: dotnet add package Microsoft.AspNetCore.Components.QuickGrid *@

<div class="time-registrations-grid">
    @if (_timeRegistrations is null || !_timeRegistrations.Any())
    {
        <p class="text-muted">No hours registered for this employee.</p>
    }
    else
    {
        <QuickGrid Items="@_timeRegistrations" Pagination="@_pagination" 
                   class="table table-hover">
            <PropertyColumn Property="@(t => t.TimeRegistrationId)" 
                            Title="ID" Sortable="true" IsDefaultSortColumn="true" />
            
            <PropertyColumn Property="@(t => t.StartTime)" 
                            Title="Start" Sortable="true" Format="MM/dd/yyyy HH:mm" />
            
            <PropertyColumn Property="@(t => t.EndTime)" 
                            Title="End" Sortable="true" Format="HH:mm" />
            
            <TemplateColumn Title="Duration">
                @{
                    var duration = context.EndTime - context.StartTime;
                    <span>@duration.Hours h @duration.Minutes min</span>
                }
            </TemplateColumn>
            
            <PropertyColumn Property="@(t => t.Description)" Title="Description" />
        </QuickGrid>
        
        <div class="d-flex align-items-center gap-3 mt-2">
            <Paginator State="@_pagination" />
            <small class="text-muted">Total: @_timeRegistrations.Count() hours</small>
        </div>
    }
</div>

@code {
    private IQueryable<TimeRegistration>? _timeRegistrations;
    private PaginationState _pagination = new() { ItemsPerPage = 10 };
    
    protected override async Task OnInitializedAsync()
    {
        var items = await TimeRegService.GetAllAsync(EmployeeId);
        _timeRegistrations = items.AsQueryable();
    }
}

5. Forms and Validation

5.1 Standard EditForm (SSR)

@* Simple SSR form — no interactivity required *@
@page "/employees/add"

<EditForm Model="@_employee" OnSubmit="HandleSubmit" 
          FormName="AddEmployee" Enhance>
    <AntiForgeryToken />
    
    <div class="mb-3">
        <label for="fn" class="form-label">First Name *</label>
        <InputText id="fn" @bind-Value="_employee.FirstName" class="form-control" />
    </div>
    
    <button type="submit" class="btn btn-primary">Save</button>
</EditForm>

@code {
    [SupplyParameterFromForm]
    private Employee _employee { get; set; } = new();
    
    private async Task HandleSubmit()
    {
        // Server-side processing
        await EmployeeService.CreateAsync(_employee);
        NavigationManager.NavigateTo("/employees");
    }
}

5.2 Complex Form with InputComponents (Interactive)

@* Interactive form with validation *@
@page "/employees/{EmployeeId:int}/edit"
@rendermode InteractiveServer

@if (_isLoading)
{
    <div class="spinner-border">...</div>
}
else if (_employee is null)
{
    <div class="alert alert-danger">Employee not found.</div>
}
else
{
    <EditForm Model="@_employee" 
              OnValidSubmit="HandleValidSubmit" 
              OnInvalidSubmit="HandleInvalidSubmit">
        <DataAnnotationsValidator />
        <ValidationSummary class="alert alert-danger" />
        
        <div class="row g-3">
            <div class="col-md-6">
                <label class="form-label">First Name *</label>
                <InputText @bind-Value="_employee.FirstName" class="form-control" />
                <ValidationMessage For="@(() => _employee.FirstName)" class="text-danger small" />
            </div>
            
            <div class="col-md-6">
                <label class="form-label">Last Name *</label>
                <InputText @bind-Value="_employee.LastName" class="form-control" />
                <ValidationMessage For="@(() => _employee.LastName)" class="text-danger small" />
            </div>
            
            <div class="col-12">
                <label class="form-label">Email *</label>
                <InputText @bind-Value="_employee.Email" class="form-control" type="email" />
                <ValidationMessage For="@(() => _employee.Email)" class="text-danger small" />
            </div>
            
            <div class="col-md-6">
                <label class="form-label">Hire Date *</label>
                <InputDate @bind-Value="_employee.HireDate" class="form-control" />
                <ValidationMessage For="@(() => _employee.HireDate)" class="text-danger small" />
            </div>
            
            <div class="col-md-6">
                <label class="form-label">Position *</label>
                <InputSelect @bind-Value="_employee.JobCategoryId" class="form-select">
                    <option value="0">-- Select --</option>
                    @foreach (var cat in _jobCategories)
                    {
                        <option value="@cat.JobCategoryId">@cat.JobCategoryName</option>
                    }
                </InputSelect>
                <ValidationMessage For="@(() => _employee.JobCategoryId)" class="text-danger small" />
            </div>
            
            <div class="col-12">
                <div class="form-check">
                    <InputCheckbox @bind-Value="_employee.IsOnHoliday" class="form-check-input" />
                    <label class="form-check-label">On Holiday</label>
                </div>
            </div>
            
            <div class="col-12">
                <label class="form-label">Profile Photo</label>
                <InputFile accept="image/*" OnChange="HandleFileUpload" class="form-control" />
            </div>
        </div>
        
        <div class="mt-4 d-flex gap-2">
            <button type="submit" class="btn btn-primary" disabled="@_isSaving">
                @(_isSaving ? "Saving..." : "Save")
            </button>
            <a href="/employees" class="btn btn-outline-secondary">Cancel</a>
        </div>
    </EditForm>
    
    @if (_saveSuccess)
    {
        <div class="alert alert-success mt-3">Employee updated successfully!</div>
    }
    @if (_saveError is not null)
    {
        <div class="alert alert-danger mt-3">@_saveError</div>
    }
}

5.3 InputFile — Image Upload

// EmployeeEdit.razor.cs
private IBrowserFile? _selectedFile;
private const long MaxFileSize = 1024 * 1024 * 2; // 2 MB

private async Task HandleFileUpload(InputFileChangeEventArgs e)
{
    _selectedFile = e.File;
    
    // Security checks
    if (_selectedFile.Size > MaxFileSize)
    {
        _saveError = "File must not exceed 2 MB.";
        return;
    }
    
    var allowedTypes = new[] { "image/jpeg", "image/png", "image/webp" };
    if (!allowedTypes.Contains(_selectedFile.ContentType))
    {
        _saveError = "Only JPEG, PNG and WebP formats are accepted.";
        return;
    }
}

private async Task HandleValidSubmit()
{
    _isSaving = true;
    _saveError = null;
    
    try
    {
        if (_selectedFile is not null)
        {
            // Read and save the image
            var extension = Path.GetExtension(_selectedFile.Name);
            var fileName = $"{_employee.EmployeeId}-{Guid.NewGuid():N}{extension}";
            var path = Path.Combine("wwwroot/images/employees", fileName);
            
            await using var fileStream = new FileStream(path, FileMode.Create);
            await using var browserStream = _selectedFile.OpenReadStream(MaxFileSize);
            await browserStream.CopyToAsync(fileStream);
            
            _employee.ProfilePictureUrl = $"/images/employees/{fileName}";
        }
        
        await EmployeeService.UpdateAsync(_employee);
        _saveSuccess = true;
    }
    catch (Exception ex)
    {
        _saveError = $"Error: {ex.Message}";
    }
    finally
    {
        _isSaving = false;
    }
}

5.4 Modern .NET 10 Validation

// New validation with nested types (.NET 10)

// Program.cs
builder.Services.AddValidation(); // Enable new validation

// Model annotated with ValidatableType
[ValidatableType]  // Blazor also validates nested types
public class Employee
{
    [Required(ErrorMessage = "First name is required")]
    [StringLength(50, MinimumLength = 2)]
    public string FirstName { get; set; } = string.Empty;
    
    // Address is a nested type — ValidatableType validates its annotations too
    public Address? HomeAddress { get; set; }
    
    // Collection — ValidatableType validates each element
    public List<EmergencyContact> EmergencyContacts { get; set; } = new();
}

public class Address
{
    [Required]
    public string Street { get; set; } = string.Empty;
    
    [Required]
    [StringLength(50)]
    public string City { get; set; } = string.Empty;
}

6. Blazor WebAssembly in an Existing Project

6.1 Server + Client Architecture

flowchart TB
    subgraph Server["BethanysPieShopHRM (Server)"]
        ServerPages[SSR Pages\nand server components]
        ServerServices[Services\nRepositories\nDbContext]
        MinimalAPI[Minimal APIs\n/api/employees]
    end
    
    subgraph Client["BethanysPieShopHRM.Client (WASM)"]
        WasmPages[WASM Pages\nInteractiveWebAssembly]
        ClientServices[Client services\nHttpClient to API]
    end
    
    Browser[Browser] -->|"SSR HTML"| Server
    Browser -->|"WASM bundle\n(after cache)"| Client
    Client -->|"HTTP GET/POST\n/api/employees"| MinimalAPI
    MinimalAPI -->|"Data"| ServerServices
    
    style Server fill:#E4F3FF
    style Client fill:#FFE4B5

6.2 Minimal API

// Program.cs — API endpoints for the WASM client
// Endpoints must be accessible by the browser (CORS if different domain)

var employeeGroup = app.MapGroup("/api/employees").RequireAuthorization();

employeeGroup.MapGet("/", async (IEmployeeDataService service) =>
    Results.Ok(await service.GetAllAsync()));

employeeGroup.MapGet("/{id:int}", async (int id, IEmployeeDataService service) =>
{
    var employee = await service.GetByIdAsync(id);
    return employee is null ? Results.NotFound() : Results.Ok(employee);
});

employeeGroup.MapPost("/", async (Employee employee, IEmployeeDataService service) =>
{
    var created = await service.CreateAsync(employee);
    return Results.Created($"/api/employees/{created.EmployeeId}", created);
});

employeeGroup.MapPut("/{id:int}", async (int id, Employee employee, IEmployeeDataService service) =>
{
    if (id != employee.EmployeeId) return Results.BadRequest();
    await service.UpdateAsync(employee);
    return Results.NoContent();
});

employeeGroup.MapDelete("/{id:int}", async (int id, IEmployeeDataService service) =>
{
    await service.DeleteAsync(id);
    return Results.NoContent();
});

6.3 Client Service for Blazor WASM

// BethanysPieShopHRM.Client/Services/ClientEmployeeDataService.cs

// This service runs in the browser via WASM
// It CANNOT directly access the database
// It must go through HTTP calls to the API
public class ClientEmployeeDataService : IEmployeeDataService
{
    private readonly HttpClient _httpClient;
    
    public ClientEmployeeDataService(HttpClient httpClient)
        => _httpClient = httpClient;
    
    public async Task<IEnumerable<Employee>> GetAllAsync()
        => await _httpClient.GetFromJsonAsync<IEnumerable<Employee>>("/api/employees")
           ?? Enumerable.Empty<Employee>();
    
    public async Task<Employee?> GetByIdAsync(int id)
        => await _httpClient.GetFromJsonAsync<Employee?>($"/api/employees/{id}");
    
    public async Task<Employee> CreateAsync(Employee employee)
    {
        var response = await _httpClient.PostAsJsonAsync("/api/employees", employee);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Employee>()
               ?? throw new InvalidOperationException("Invalid response");
    }
    
    public async Task UpdateAsync(Employee employee)
    {
        var response = await _httpClient.PutAsJsonAsync(
            $"/api/employees/{employee.EmployeeId}", employee);
        response.EnsureSuccessStatusCode();
    }
    
    public async Task DeleteAsync(int id)
    {
        var response = await _httpClient.DeleteAsync($"/api/employees/{id}");
        response.EnsureSuccessStatusCode();
    }
}
// BethanysPieShopHRM.Client/Program.cs
var builder = WebAssemblyHostBuilder.CreateDefault(args);

// HttpClient points to the host (where the API resides)
builder.Services.AddScoped(sp =>
    new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

// WASM implementation — uses HttpClient instead of repository
builder.Services.AddScoped<IEmployeeDataService, ClientEmployeeDataService>();

// Client-side authorization services
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();

await builder.Build().RunAsync();

6.4 Auto Render Mode — The Ultimate Mode

// Page in Auto mode — starts on server, switches to WASM
@page "/employees/{EmployeeId:int}/edit"
@rendermode InteractiveAuto  // ← Magic!

@* Behavior:
   1st visit: rendered via SignalR (fast, no download)
   Subsequent visits: rendered via WASM (offline, no SignalR)
   The same service implementation is used thanks to DI! *@

Detecting the render mode:

@* Useful to adapt behavior based on context *@
@code {
    protected override void OnInitialized()
    {
        // RendererInfo.Name: "Static", "Server", "WebAssembly", "WebView"
        Console.WriteLine($"Current mode: {RendererInfo.Name}");
        
        // RendererInfo.IsInteractive: true when interactivity is ready
        if (!RendererInfo.IsInteractive)
        {
            // Show a placeholder while waiting
        }
    }
}

7. JavaScript Interop and Razor Class Libraries

7.1 Interactive Map with Leaflet.js

// Components/MapComponent.razor.cs
public partial class MapComponent : IAsyncDisposable
{
    [Parameter]
    public IEnumerable<MapMarker> Markers { get; set; } = Enumerable.Empty<MapMarker>();
    
    [Inject] private IJSRuntime JSRuntime { get; set; } = default!;
    
    private IJSObjectReference? _mapModule;
    
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            // Load the ES6 module
            _mapModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
                "import", "./js/mapComponent.js");
            
            // Initialize the map with markers
            await _mapModule.InvokeVoidAsync("initializeMap", "mapContainer", Markers);
        }
    }
    
    public async ValueTask DisposeAsync()
    {
        if (_mapModule is not null)
        {
            await _mapModule.InvokeVoidAsync("destroyMap", "mapContainer");
            await _mapModule.DisposeAsync();
        }
    }
}
// wwwroot/js/mapComponent.js (ES6 module)
import L from 'https://esm.run/leaflet@1.9.4';

let mapInstance = null;

export function initializeMap(containerId, markers) {
    const container = document.getElementById(containerId);
    if (!container) return;
    
    // Create Leaflet map
    mapInstance = L.map(containerId).setView([51.5074, -0.1278], 10); // London
    
    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '© OpenStreetMap contributors'
    }).addTo(mapInstance);
    
    // Add markers
    markers.forEach(marker => {
        const leafletMarker = L.marker([marker.latitude, marker.longitude])
            .bindPopup(`<b>${marker.name}</b><br/>${marker.description}`)
            .addTo(mapInstance);
        
        if (marker.showPopupOnLoad) {
            leafletMarker.openPopup();
        }
    });
    
    // Adjust view to show all markers
    if (markers.length > 0) {
        const group = L.featureGroup(mapInstance.getLayers());
        mapInstance.fitBounds(group.getBounds().pad(0.1));
    }
}

export function destroyMap(containerId) {
    if (mapInstance) {
        mapInstance.remove();
        mapInstance = null;
    }
}

7.2 Razor Class Library for Shared Components

// In BethanysPieShopHRM.ComponentsLibrary

// MapMarker model in the library
public class MapMarker
{
    public double Latitude { get; set; }
    public double Longitude { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public bool ShowPopupOnLoad { get; set; }
}

Using the library in the main project:

// In the main project .csproj
<ProjectReference Include="..\BethanysPieShopHRM.ComponentsLibrary\ComponentsLibrary.csproj" />
@* Using MapComponent from the library *@
@using BethanysPieShopHRM.ComponentsLibrary.Components

<MapComponent Markers="@_markers" />

@code {
    private IEnumerable<MapMarker> _markers = new[]
    {
        new MapMarker
        {
            Latitude = Employee!.Latitude ?? 51.51,
            Longitude = Employee.Longitude ?? -0.13,
            Name = Employee.FullName,
            Description = Employee.Email,
            ShowPopupOnLoad = true
        }
    };
}

8. Authentication and Authorization

8.1 Configure ASP.NET Core Identity

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

builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme)
    .AddIdentityCookies();

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

8.2 Securing the Application

@* Routes.razor *@
<Router AppAssembly="typeof(Program).Assembly"
        AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
            <NotAuthorized>
                @if (context.User.Identity?.IsAuthenticated ?? false)
                {
                    <p>Access denied.</p>
                }
                else
                {
                    <RedirectToLogin />
                }
            </NotAuthorized>
        </AuthorizeRouteView>
    </Found>
</Router>

@* NavMenu.razor — Conditional navigation *@
<AuthorizeView>
    <Authorized>
        <NavLink href="/employees">Employees</NavLink>
        @* Logout via POST form *@
        <form method="post" action="/Account/Logout">
            <AntiForgeryToken />
            <button type="submit">Sign Out (@context.User.Identity!.Name)</button>
        </form>
    </Authorized>
    <NotAuthorized>
        <NavLink href="/Account/Register">Register</NavLink>
        <NavLink href="/Account/Login">Login</NavLink>
    </NotAuthorized>
</AuthorizeView>

9. Production Best Practices

9.1 Pre-Deployment Checklist

CategoryCheckPriority
Security[Authorize] on all sensitive pagesCritical
SecurityAPI endpoints protected with .RequireAuthorization()Critical
PerformanceAsNoTracking() on all EF readsHigh
PerformanceVirtualize or pagination on large listsHigh
UXStreamRendering on slow-loading pagesMedium
RobustnessErrorBoundary on critical componentsHigh
MaintainabilitySeparate code-behind for logicHigh
DebuggingStructured logs in productionHigh
DeploymentMigrations applied at startupCritical
TestingbUnit for critical componentsHigh

9.2 Auto Migrations at Startup

// Program.cs — Apply migrations automatically
var app = builder.Build();

// Apply migrations in production
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    try
    {
        // Apply pending migrations
        await db.Database.MigrateAsync();
        
        // Seed reference data if empty
        if (!await db.Countries.AnyAsync())
        {
            await SeedReferenceDataAsync(db);
        }
    }
    catch (Exception ex)
    {
        var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
        logger.LogError(ex, "Error during database migration.");
        throw;
    }
}

10. Practical Exercises

Exercise 1 — SQLite Migration (Intermediate, ~1h)

Objective: Migrate from InMemory to SQLite.

Steps:

  1. Add Microsoft.EntityFrameworkCore.Sqlite and .Tools
  2. Modify Program.cs to use UseSqlite()
  3. Create the InitialMigration migration
  4. Add seed data via HasData
  5. Apply the migration

Exercise 2 — Complete Edit Form (Advanced, ~3h)

Objective: Interactive form to edit an employee with photo upload.

Deliverable:

  • Page /employees/{id}/edit with [rendermode InteractiveServer]
  • EditForm with DataAnnotationsValidator
  • InputFile for profile photo
  • Save to wwwroot/images/employees/

Exercise 3 — WASM Mode for a Page (Advanced, ~2h)

Objective: Run the employee list in WASM.

Steps:

  1. Create the client project
  2. Add Minimal APIs
  3. Create ClientEmployeeDataService
  4. Move EmployeeOverview to the client project
  5. Switch to InteractiveAuto mode

11. Glossary

TermDefinition
Auto Render ModeBlazor mode using Server on first render, then WASM after caching
CascadeBlazor mechanism passing values to all descendant components
ChildContentConventional RenderFragment property for content between tags
DataAnnotationsValidatorBlazor component enabling validation via [Required] attributes, etc.
EditFormBlazor component managing forms with model binding and validation
ErrorBoundaryComponent isolating exceptions to prevent the whole page from crashing
InputFileBlazor component for file uploads
IQueryableInterface allowing deferred queries (for QuickGrid)
ItemsProviderVirtualize delegate for on-demand data loading
MapGroupMinimal API endpoint grouping method
Minimal APILightweight ASP.NET Core API with MapGet/MapPost/MapPut/MapDelete
MigrationEF Core code capturing database schema changes
PageTitleBlazor component setting the browser tab title
PaginationStatePagination state shared with QuickGrid
QuickGridBlazor data grid component with sorting, pagination, filtering
RendererInfoInformation about the current render mode of a component
RenderFragmentType representing an HTML content fragment (see ChildContent)
SQLiteLightweight cross-platform database, ideal for development
StreamRenderingTechnique sending the initial UI then completing it with data
ValidatableType.NET 10 attribute enabling validation on nested types
VirtualizeBlazor component for virtual rendering of large lists

Resources


Detailed Table of Contents

  1. Advanced Components
  2. Entity Framework Core with SQLite
  3. State Management
  4. Virtualize and QuickGrid Components
  5. Forms and Validation
  6. Blazor WebAssembly in an Existing Project
  7. JavaScript Interop and Razor Class Libraries
  8. Authentication and Authorization
  9. Production Best Practices

Search Terms

web · applications · blazor · ui · c# · .net · development · components · core · sqlite · auto · child · class · client · data · form · interactive · migrations · mode · quickgrid · razor · service · shared · validation

Interested in this course?

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