In-Depth Guide · 10 Sections · Blazor
Blazor In Depth
From render modes (Server/WASM/Auto) and components to HttpClient data fetching, EF Core, AuthorizeView, forms validation, brief JS interop, deployment and production hardening. Plain English, copy-paste .NET 9 code.
Render Modes
Components & Data
Auth & Forms
Deploy & Production
Prerequisite: C# basics — C# for Dummies or Complete C# for Dummies.
For backend APIs see Minimal API or REST API.
Database access assumes EF Core In Depth; authentication builds on ASP.NET Core Identity.
Blazor lets you build interactive web UIs in C# instead of JavaScript. .NET 8+ unified render modes let you mix Server, WebAssembly, and static rendering in one app — pick per component or page.
1.1
Server vs WASM vs Auto
// InteractiveServer — UI events over SignalR; fast first load, needs connection
@rendermode InteractiveServer
// InteractiveWebAssembly — runs in browser; offline-capable, larger download
@rendermode InteractiveWebAssembly
// InteractiveAuto — Server first, then WASM after download (best of both)
@rendermode InteractiveAuto
// Static SSR — no interactivity; great for SEO landing pages
@rendermode Static // or omit on .razor page (default in many templates)
// Choose by scenario:
// Internal admin app → InteractiveServer (low latency, secure DB access)
// Public SPA → InteractiveWebAssembly
// Mixed marketing + app → InteractiveAuto or per-page modes
1.2
Blazor vs SPA frameworks
// Blazor advantages:
// • One language (C#) front-to-back
// • Share DTOs, validation, auth with API layer
// • Server mode — no WASM download, direct DI to DbContext
// Blazor trade-offs:
// • Server mode — SignalR scale-out needs sticky sessions or Azure SignalR
// • WASM — initial payload size; use trimming & lazy load
// • Ecosystem smaller than React — but growing fast
// Hybrid: Blazor UI + Minimal API backend (common enterprise pattern)
The Blazor Web App template (.NET 8+) supports all render modes in one project. Structure: Components/ for UI, Program.cs for services, optional Client/ project for WASM.
dotnet new blazor -n MyShop -int Auto --empty false
cd MyShop
dotnet run
// Program.cs — register render modes
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(Client._Imports).Assembly);
2.2
App.razor, Routes & _Imports
// Components/App.razor
<Routes />
// Components/Routes.razor
<Router AppAssembly="typeof(Program).Assembly"
AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
</Found>
</Router>
// Components/_Imports.razor — global usings
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
Blazor components are reusable UI units — .razor files mix HTML and C#. Pass data with parameters, share context with cascading values, and react to events with EventCallback.
3.1
Parameters & EventCallback
// ProductCard.razor
<article class="card">
<h3>@Product.Name</h3>
<p>@Product.Price.ToString("C")</p>
<button @onclick="OnAddToCart">Add to cart</button>
</article>
@code {
[Parameter, EditorRequired]
public ProductDto Product { get; set; } = default!;
[Parameter]
public EventCallback<ProductDto> OnAdded { get; set; }
private async Task OnAddToCart() => await OnAdded.InvokeAsync(Product);
}
// Parent: <ProductCard Product="item" OnAdded="HandleAdded" />
3.2
Cascading parameters & lifecycle
// Layout provides theme to all descendants
<CascadingValue Value="Theme" IsFixed="true">
@Body
</CascadingValue>
// Child reads it
[Parameter]
public string Theme { get; set; } = "light"; // or [CascadingParameter]
// Lifecycle hooks
protected override async Task OnInitializedAsync()
=> _products = await ProductService.GetAllAsync();
protected override void OnParametersSet()
{
// Runs when parent passes new parameter values
}
// ❌ Don't call StateHasChanged in OnInitialized unless needed
In WASM and Auto modes, call your API with HttpClient. In Server mode you can inject services directly — but HttpClient keeps a clean boundary and works in all modes.
4.1
Typed HttpClient service
// Program.cs
builder.Services.AddHttpClient<IProductApi, ProductApi>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!);
});
// ProductApi.cs
public sealed class ProductApi(HttpClient http) : IProductApi
{
public async Task<List<ProductDto>> GetAllAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<ProductDto>>("api/products", ct)
?? [];
}
// Products.razor
@inject IProductApi Api
@if (_loading) { <p>Loading…</p> }
else { @foreach (var p in _products) { <ProductCard Product="p" /> } }
4.2
Loading states & error handling
@code {
private List<ProductDto> _products = [];
private bool _loading = true;
private string? _error;
protected override async Task OnInitializedAsync()
{
try
{
_products = await Api.GetAllAsync();
}
catch (HttpRequestException ex)
{
_error = "Could not load products. Try again later.";
Logger.LogError(ex, "Product fetch failed");
}
finally { _loading = false; }
}
}
// Server mode: IHttpClientFactory avoids socket exhaustion
Interactive Server can inject DbContext directly — but DbContext is scoped per circuit, not per HTTP request. Use a factory pattern and keep operations short.
5.1
DbContextFactory pattern
// Program.cs — preferred for Blazor Server
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlServer(connString));
// Component or service
public sealed class ProductService(IDbContextFactory<AppDbContext> factory)
{
public async Task<List<Product>> GetActiveAsync(CancellationToken ct)
{
await using var db = await factory.CreateDbContextAsync(ct);
return await db.Products.AsNoTracking()
.Where(p => p.IsActive).ToListAsync(ct);
}
}
5.2
WASM — never put DbContext in browser
// ❌ WASM runs in browser — connection strings exposed
// ✅ WASM always calls API; Server mode may use factory directly
// Circuit scope gotcha — one user, long-lived circuit:
// • Don't hold DbContext as component field
// • Create/dispose per operation via factory
// • Use AsNoTracking for read-only grids
// Scale-out: Azure SignalR Service + sticky sessions or stateless design
Watch Out — DbContext lifetime in Blazor Server
Blazor Server circuits outlive a single HTTP request. Injecting scoped DbContext into a singleton or long-lived component causes stale data and threading issues. Always use IDbContextFactory<T>.
Blazor integrates with ASP.NET Core Identity and cookie or JWT auth. Use AuthorizeView and [Authorize] to show UI based on roles and policies.
6.1
AuthorizeView & cascading auth
<AuthorizeView Roles="Admin">
<Authorized>
<button @onclick="DeleteAll">Delete all</button>
</Authorized>
<NotAuthorized>
<p>Admin access required.</p>
</NotAuthorized>
</AuthorizeView>
<AuthorizeView Policy="CanEditProducts">
<Authorized Context="auth">
<p>Hello, @auth.User.Identity?.Name</p>
</Authorized>
</AuthorizeView>
// Page-level
@attribute [Authorize(Roles = "Manager")]
6.2
Server vs WASM auth setup
// Server — cookie auth works out of the box
builder.Services.AddAuthentication()
.AddCookie();
builder.Services.AddCascadingAuthenticationState();
// WASM — use OIDC or API tokens
builder.Services.AddOidcAuthentication(options =>
{
builder.Configuration.Bind("Auth0", options.ProviderOptions);
});
// Pass token to HttpClient
builder.Services.AddHttpClient("API", client => ...)
.AddHttpMessageHandler<AuthorizationMessageHandler>();
When Blazor can't do something natively — chart libraries, file downloads, browser APIs — use JavaScript interop. Keep JS thin; call from C# via IJSRuntime.
8.1
IJSRuntime — invoke JS from C#
@inject IJSRuntime JS
<button @onclick="CopyToClipboard">Copy</button>
@code {
private async Task CopyToClipboard()
=> await JS.InvokeVoidAsync("navigator.clipboard.writeText", _text);
// Module import (.NET 8+)
private IJSObjectReference? _module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
_module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./js/charts.js");
}
}
8.2
JSInvokable — call C# from JS
// wwwroot/js/charts.js
export function initChart(dotNetRef) {
chart.on('click', () => dotNetRef.invokeMethodAsync('OnChartClick', pointId));
}
// Component
private DotNetObjectReference<ChartComponent>? _dotNetRef;
[JSInvokable]
public void OnChartClick(int pointId) => SelectedId = pointId;
// ⚠️ Dispose DotNetObjectReference in Dispose()
// ⚠️ Avoid JS interop in OnInitialized — use OnAfterRenderAsync
Deployment depends on render mode: Server apps host like any ASP.NET Core site; WASM publishes static files plus a small API; Auto needs both.
// Server / Auto (hosted)
dotnet publish -c Release -o ./publish
// WASM standalone — static files in wwwroot
dotnet publish -c Release
// Docker
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY publish/ .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyShop.dll"]
9.2
Azure & CDN for WASM assets
// Azure App Service — Blazor Server + Auto
// Enable WebSockets (required for SignalR)
// Scale-out: Azure SignalR Service
// WASM static assets — Azure Static Web Apps or Blob + CDN
// • gzip/brotli _framework/*.wasm
// • Long cache headers on hashed files
// • Lazy load assemblies: <LazyAssemblyLoader />
// CI: dotnet publish → deploy artifact → health check /health
10.1
Server mode production checklist
- Azure SignalR Service when scaling beyond one instance.
- Circuit disconnect — handle
OnCircuitOpened/Closed; show reconnect UI.
- Memory — circuits hold state; set idle timeout; avoid large object graphs in components.
- Prerender static HTML for first paint, then hydrate interactivity.
// Reconnect UI — App.razor
<ReconnectModal />
// Configure circuit options
builder.Services.AddServerSideBlazor(options =>
{
options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(3);
options.MaxBufferedUnacknowledgedRenderBatches = 10;
});
10.2
Testing & monitoring
// bUnit — component unit tests
// Package: bunit
[Fact]
public void ProductCard_RendersName()
{
using var ctx = new BunitContext();
var cut = ctx.Render<ProductCard>(p => p
.Add(x => x.Product, new ProductDto(1, "Widget", 9.99m)));
cut.Find("h3").TextContent.Should().Be("Widget");
}
// OpenTelemetry + App Insights — track circuit errors, render duration