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
- Advanced Components
- Entity Framework Core with SQLite
- State Management
- Virtualize and QuickGrid Components
- Forms and Validation
- Blazor WebAssembly in an Existing Project
- JavaScript Interop and Razor Class Libraries
- Authentication and Authorization
- Production Best Practices
- Practical Exercises
- 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
| Category | Check | Priority |
|---|---|---|
| Security | [Authorize] on all sensitive pages | Critical |
| Security | API endpoints protected with .RequireAuthorization() | Critical |
| Performance | AsNoTracking() on all EF reads | High |
| Performance | Virtualize or pagination on large lists | High |
| UX | StreamRendering on slow-loading pages | Medium |
| Robustness | ErrorBoundary on critical components | High |
| Maintainability | Separate code-behind for logic | High |
| Debugging | Structured logs in production | High |
| Deployment | Migrations applied at startup | Critical |
| Testing | bUnit for critical components | High |
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:
- Add
Microsoft.EntityFrameworkCore.Sqliteand.Tools - Modify Program.cs to use
UseSqlite() - Create the
InitialMigrationmigration - Add seed data via
HasData - Apply the migration
Exercise 2 — Complete Edit Form (Advanced, ~3h)
Objective: Interactive form to edit an employee with photo upload.
Deliverable:
- Page
/employees/{id}/editwith[rendermode InteractiveServer] EditFormwithDataAnnotationsValidatorInputFilefor profile photo- Save to
wwwroot/images/employees/
Exercise 3 — WASM Mode for a Page (Advanced, ~2h)
Objective: Run the employee list in WASM.
Steps:
- Create the client project
- Add Minimal APIs
- Create
ClientEmployeeDataService - Move
EmployeeOverviewto the client project - Switch to
InteractiveAutomode
11. Glossary
| Term | Definition |
|---|---|
| Auto Render Mode | Blazor mode using Server on first render, then WASM after caching |
| Cascade | Blazor mechanism passing values to all descendant components |
| ChildContent | Conventional RenderFragment property for content between tags |
| DataAnnotationsValidator | Blazor component enabling validation via [Required] attributes, etc. |
| EditForm | Blazor component managing forms with model binding and validation |
| ErrorBoundary | Component isolating exceptions to prevent the whole page from crashing |
| InputFile | Blazor component for file uploads |
| IQueryable | Interface allowing deferred queries (for QuickGrid) |
| ItemsProvider | Virtualize delegate for on-demand data loading |
| MapGroup | Minimal API endpoint grouping method |
| Minimal API | Lightweight ASP.NET Core API with MapGet/MapPost/MapPut/MapDelete |
| Migration | EF Core code capturing database schema changes |
| PageTitle | Blazor component setting the browser tab title |
| PaginationState | Pagination state shared with QuickGrid |
| QuickGrid | Blazor data grid component with sorting, pagination, filtering |
| RendererInfo | Information about the current render mode of a component |
| RenderFragment | Type representing an HTML content fragment (see ChildContent) |
| SQLite | Lightweight cross-platform database, ideal for development |
| StreamRendering | Technique sending the initial UI then completing it with data |
| ValidatableType | .NET 10 attribute enabling validation on nested types |
| Virtualize | Blazor component for virtual rendering of large lists |
Resources
- Prerequisite course: “Blazor Foundations” (Gill Cleeren)
- QuickGrid docs: learn.microsoft.com/aspnet/core/blazor/components/quickgrid
- Leaflet.js: leafletjs.com — Mapping library used in demos
- EF Core SQLite: learn.microsoft.com/ef/core/providers/sqlite
Detailed Table of Contents
- Advanced Components
- Entity Framework Core with SQLite
- State Management
- Virtualize and QuickGrid Components
- Forms and Validation
- Blazor WebAssembly in an Existing Project
- JavaScript Interop and Razor Class Libraries
- Authentication and Authorization
- 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