Course: Blazor Fundamentals (.NET 8/9) Demo Application: Bethany’s Pie Shop HRM Level: Intermediate | Estimated Duration: ~6h
Table of Contents
- Blazor Overview and Render Modes
- Blazor Web App Project Structure
- Razor Components — Advanced Fundamentals
- Data Access with EF Core
- State Management
- Forms and Validation
- Blazor WebAssembly (WASM)
- JavaScript Interop
- Authentication and Authorization
- Testing with bUnit
- Advanced Components — Virtualize and QuickGrid
- Deployment on Azure
- Best Practices
- Practical Exercises
- 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 Mode | Execution | Use Case | Interactivity |
|---|---|---|---|
| Static SSR | Server → Static HTML | Public pages, SEO | ❌ None |
| Interactive Server | Server via SignalR | Internal apps, direct DB access | ✅ Full |
| Interactive WASM | Browser via WebAssembly | PWA, offline apps | ✅ Full |
| Interactive Auto | Server then WASM | General 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
| Category | Practice | Why |
|---|---|---|
| Code | Use code-behind (.razor.cs) | Clear UI/logic separation |
| Code | Inject via [Inject] or constructor | DI = testability |
| Performance | AsNoTracking() for reads | Less EF Core memory |
| Performance | Virtualize for large lists | Partial rendering = performance |
| Security | [Authorize] on sensitive pages | Server-side protection |
| Security | Validate route parameters | Injection prevention |
| UX | StreamRendering for loading | Immediate feedback |
| Maintenance | ErrorBoundary on critical components | Error isolation |
| Testing | bUnit for Razor components | Quality and regressions |
| Accessibility | role, aria-label on interactive elements | WCAG 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/addwithEditForm - 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 inApplicationStateEmployeeStats→ 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 renderingEmployeeCard—OnQuickViewClickedcallbackEmployeeOverview— spinner shown during loadingAddEmployee— validation prevents submissionAddEmployee— success navigates to list
15. Glossary
| Term | Definition |
|---|---|
| ASP.NET Core Identity | .NET framework for managing user authentication |
| bUnit | Unit testing framework for Blazor components |
| CascadingParameter | Parameter automatically passed to all descendant components |
| Circuit | SignalR session between a user and their Blazor Server instance |
| DataAnnotations | C# attributes defining validation rules on models |
| DbContextFactory | Service creating thread-safe DbContext instances |
| DotNetObjectReference | Reference to a .NET object usable from JavaScript |
| DynamicComponent | Blazor component loading a component by its type at runtime |
| EditForm | Blazor component managing forms with validation |
| ErrorBoundary | Component isolating exceptions at a subtree level |
| EventCallback | Blazor type for parent-child callbacks, thread-safe and async |
| IJSObjectReference | Reference to a JavaScript object returned by an ES6 module |
| IJSRuntime | Injection service for invoking JavaScript from Blazor |
| IQueryable | Interface allowing deferred execution of a data query |
| ItemsProviderRequest | Paginated data request for the Virtualize component |
| NSubstitute / Moq | Mocking libraries for creating fake objects in tests |
| PaginationState | Pagination state shared with the QuickGrid component |
| QuickGrid | Blazor paginated and sortable data grid component |
| RenderFragment | Delegate representing a fragment of HTML content |
| Repository Pattern | Pattern 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 |
| StateHasChanged | Blazor method indicating that state has changed and a re-render is needed |
| Virtualize | Blazor component improving performance by only rendering visible items |
| WASM | WebAssembly — .NET bytecode running in the browser |
| WebAssemblyHostBuilder | Builder for configuring a Blazor WASM application |
Additional Resources
- Follow-up course: “Building Web Applications with Blazor” (Gill Cleeren)
- Official documentation: learn.microsoft.com/aspnet/core/blazor
- bUnit: bunit.dev
- QuickGrid: learn.microsoft.com/aspnet/core/blazor/components/quickgrid
- EF Core: learn.microsoft.com/ef/core
- JavaScript Interop: learn.microsoft.com/aspnet/core/blazor/javascript-interoperability
- GitHub Blazor: github.com/dotnet/aspnetcore
Table of Contents
- Blazor Overview
- Render Modes and Hosting Models
- Blazor Project Structure
- Razor Components
- Navigation and Routing
- Entity Framework Core and Data
- State Management
- Advanced Components
- Forms and Validation
- Blazor WebAssembly (WASM)
- JavaScript Interop
- Authentication and Authorization
- Testing with bUnit
- Deployment on Azure App Service
- .NET Aspire
- 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+):
| Mode | Execution | Connection | Usage |
|---|---|---|---|
| Static SSR | Server → HTML → Browser | Classic Request/Response | Static pages, SEO |
| Interactive Server | Server (SignalR) | SignalR circuit open | Rich interactivity, direct DB access |
| Interactive WebAssembly | Browser (WASM) | None after download | Offline, CDN, API required |
| Interactive Auto | Server → then WASM | Starts Server, switches to WASM | Best 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:
| Constraint | Description |
|---|---|
:int | Integer |
:guid | GUID |
:bool | Boolean |
:datetime | Date |
:decimal | Decimal |
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:
| Tier | Usage | Features |
|---|---|---|
| Free/Shared | Dev, test | 60 min CPU/day |
| Basic (B1-B3) | Dev, test | Manual scale |
| Standard (S1-S3) | Production | Auto-scale |
| Premium (P1v3+) | High performance | VNet 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
| Concept | Description |
|---|---|
| Blazor | ASP.NET Core framework for interactive UIs in C# |
| Static SSR | Server-side HTML rendering (request/response) |
| Interactive Server | C# runs on the server via SignalR |
| Interactive WASM | C# compiled and executed in the browser |
| InteractiveAuto | Starts as Server, automatically switches to WASM |
| @page | Directive to make a component navigable |
| @rendermode | Specify the render mode of a component |
| [Parameter] | Property received from the parent component |
| EventCallback | Typed event sent to the parent |
| StreamRendering | Display content immediately + progressive loading |
| EditForm | Form with data annotations validation |
| IDbContextFactory | Create DbContext instances in Blazor Server |
| bUnit | Testing framework for Blazor components |
| Virtualize | Render only visible items in a long list |
| QuickGrid | Paginated 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