.NET & C# Development · Lesson 218 of 229
Blazor in .NET 9 — WebAssembly, Server & Auto Render Modes
Blazor in .NET 9 — WebAssembly, Server, and Auto Render Modes
Blazor lets you build interactive UIs with C# instead of JavaScript. In .NET 9, Blazor Unified — a single project targeting multiple render modes — is the default model. Understanding which render mode to use where is the central skill.
What you'll learn:
- Component lifecycle and event handling
- Render modes: Server, WebAssembly, Auto — when to use each
- Forms with EditForm and FluentValidation
- State management without a framework
- JavaScript interop for browser-only APIs
- Authentication and authorization in Blazor
- Deployment patterns (static hosting vs server)
The Three Render Modes
| Mode | Where it runs | Connection needed | First paint | Best for | |---|---|---|---|---| | Static SSR | Server, no interactivity | None | Fastest | Read-only pages, SEO content | | Blazor Server | Server via SignalR | Persistent WebSocket | Fast | Admin UIs, dashboards, auth-heavy | | Blazor WebAssembly | Browser | None after load | Slower (WASM download) | Offline apps, heavy client logic | | Auto | Server first, then WASM | WebSocket until WASM ready | Fast, then fully offline | General-purpose interactive pages |
.NET 9 lets you set render mode per component, per page, or globally.
Project Structure
src/
MyApp.Web/ ← Blazor Web App (server project)
Components/
App.razor
Routes.razor
Pages/
Home.razor
Dashboard.razor
Products/
ProductList.razor
ProductDetail.razor
Shared/
MainLayout.razor
NavMenu.razor
UI/
DataTable.razor
LoadingSpinner.razor
MyApp.Client/ ← WebAssembly client project
Pages/ ← Pages that run in WASM
Counter.razor
wwwroot/
MyApp.Core/ ← Shared models, interfaces
MyApp.Infrastructure/ ← Data access (server-only)1. Component Model
Every Blazor component is a .razor file — HTML markup with C# logic in a @code block.
@* Components/Pages/Products/ProductList.razor *@
@page "/products"
@rendermode InteractiveServer
@inject IProductService ProductService
@inject NavigationManager Nav
<PageTitle>Products</PageTitle>
<div class="page-header">
<h1>Products</h1>
<button class="btn-primary" @onclick="OpenCreateModal">Add Product</button>
</div>
@if (_loading)
{
<LoadingSpinner />
}
else if (_products.Count == 0)
{
<EmptyState Message="No products yet." />
}
else
{
<DataTable Items="_products" TItem="ProductDto">
<HeaderContent>
<th>Name</th>
<th>SKU</th>
<th>Price</th>
<th>Stock</th>
<th></th>
</HeaderContent>
<RowContent Context="product">
<td>@product.Name</td>
<td class="text-muted">@product.Sku</td>
<td>@product.Price.ToString("C")</td>
<td>
<StockBadge Available="product.StockAvailable" />
</td>
<td>
<button @onclick="() => Nav.NavigateTo($"/products/{product.Id}")">
View
</button>
</td>
</RowContent>
</DataTable>
}
@code {
private List<ProductDto> _products = [];
private bool _loading = true;
protected override async Task OnInitializedAsync()
{
_products = await ProductService.GetAllAsync();
_loading = false;
}
private void OpenCreateModal()
{
Nav.NavigateTo("/products/create");
}
}Component lifecycle
OnInitialized / OnInitializedAsync ← set up state, call services
OnParametersSet / OnParametersSetAsync ← reacts to parent parameter changes
OnAfterRender / OnAfterRenderAsync ← safe to call JS, measure DOM
ShouldRender ← return false to skip re-render
IDisposable.Dispose ← unsubscribe events, cancel tokens@code {
[Parameter] public int ProductId { get; set; }
private ProductDto? _product;
private CancellationTokenSource _cts = new();
protected override async Task OnParametersSetAsync()
{
// Fires when ProductId parameter changes — navigate between products
_product = null;
_product = await ProductService.GetByIdAsync(ProductId, _cts.Token);
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}2. Render Modes in Practice
Static SSR — no directive needed
@* Components/Pages/Home.razor *@
@page "/"
@inject IContentService Content
<h1>@_headline</h1>
<p>@_intro</p>
@code {
private string _headline = default!;
private string _intro = default!;
protected override async Task OnInitializedAsync()
{
var content = await Content.GetHomePageAsync();
_headline = content.Headline;
_intro = content.Intro;
}
}No @rendermode = static SSR. The page renders on the server to HTML, no WebSocket opened, no JS shipped beyond what you explicitly include. Perfect for marketing pages and blog content.
Blazor Server — persistent SignalR
@rendermode InteractiveServerUI events go to the server over WebSocket. The server processes them and pushes DOM diffs back. Works with any server-side service (EF Core, file system, etc.) directly — no API calls needed.
Use when: you need access to server resources (databases, internal APIs) and can afford the persistent connection. Admin dashboards, internal tools, anything requiring real-time server push.
WebAssembly — fully in the browser
@* Only in MyApp.Client project *@
@rendermode InteractiveWebAssembly
@inject HttpClient Http ← must call APIs, no direct DB access
@code {
private WeatherForecast[]? _forecasts;
protected override async Task OnInitializedAsync()
{
_forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("api/weather");
}
}Use when: offline capability matters, you want zero server load after initial load, or you're building something that runs entirely in the browser.
Downside: Initial load downloads the .NET runtime and your assemblies (~5-15 MB, cached after first visit). Not suitable for public-facing pages where first paint speed is critical.
Auto render mode — best of both
@rendermode InteractiveAutoFirst visit: renders via Blazor Server (fast first paint, no WASM download). In the background, WASM downloads. Subsequent visits: renders in WASM (no server connection). The component code must work in both environments — no server-side-only dependencies.
3. Forms and Validation
@* Components/Pages/Products/CreateProduct.razor *@
@page "/products/create"
@rendermode InteractiveServer
@inject IProductService Products
@inject NavigationManager Nav
<h2>Create Product</h2>
<EditForm Model="_model" OnValidSubmit="HandleSubmit" FormName="create-product">
<DataAnnotationsValidator />
<div class="form-group">
<label for="name">Product Name</label>
<InputText id="name" @bind-Value="_model.Name" class="form-control" />
<ValidationMessage For="() => _model.Name" />
</div>
<div class="form-group">
<label for="price">Price</label>
<InputNumber id="price" @bind-Value="_model.Price" class="form-control" />
<ValidationMessage For="() => _model.Price" />
</div>
<div class="form-group">
<label for="category">Category</label>
<InputSelect id="category" @bind-Value="_model.CategoryId" class="form-control">
<option value="">Select category...</option>
@foreach (var cat in _categories)
{
<option value="@cat.Id">@cat.Name</option>
}
</InputSelect>
<ValidationMessage For="() => _model.CategoryId" />
</div>
@if (_error is not null)
{
<div class="alert alert-danger">@_error</div>
}
<button type="submit" class="btn-primary" disabled="@_submitting">
@(_submitting ? "Creating..." : "Create Product")
</button>
</EditForm>
@code {
private CreateProductModel _model = new();
private List<CategoryDto> _categories = [];
private bool _submitting;
private string? _error;
protected override async Task OnInitializedAsync()
{
_categories = await Products.GetCategoriesAsync();
}
private async Task HandleSubmit()
{
_submitting = true;
_error = null;
try
{
var id = await Products.CreateAsync(_model);
Nav.NavigateTo($"/products/{id}");
}
catch (ValidationException ex)
{
_error = ex.Message;
}
finally
{
_submitting = false;
}
}
}// Models/CreateProductModel.cs
public class CreateProductModel
{
[Required, StringLength(200, MinimumLength = 2)]
public string Name { get; set; } = "";
[Required, StringLength(50)]
public string Sku { get; set; } = "";
[Required, Range(0.01, 999_999)]
public decimal Price { get; set; }
[Required]
public int? CategoryId { get; set; }
[StringLength(2000)]
public string? Description { get; set; }
}Static SSR forms (no render mode)
In .NET 9 static SSR, forms work without JavaScript via standard POST:
@page "/contact"
@inject IContactService Contacts
<form method="post" @formname="contact-form">
<AntiforgeryToken />
<input name="Name" placeholder="Your name" />
<input name="Email" type="email" placeholder="Email" />
<button type="submit">Send</button>
</form>
@code {
[SupplyParameterFromForm]
public ContactFormModel? Model { get; set; }
protected override async Task OnInitializedAsync()
{
if (Model is not null)
{
await Contacts.SubmitAsync(Model);
}
}
}4. State Management
Blazor has no built-in store like Redux. For most apps, three patterns cover all cases:
Cascading parameters (parent → deep child)
@* App.razor or a layout *@
<CascadingValue Value="_currentUser">
@Body
</CascadingValue>
@code {
private UserContext? _currentUser;
protected override async Task OnInitializedAsync()
{
_currentUser = await Auth.GetCurrentUserAsync();
}
}@* Any deep child *@
@code {
[CascadingParameter] private UserContext? CurrentUser { get; set; }
}State service (app-level singleton)
// Services/AppState.cs
public class AppState
{
private int _cartItemCount;
public int CartItemCount => _cartItemCount;
public event Action? OnChange;
public void SetCartCount(int count)
{
_cartItemCount = count;
OnChange?.Invoke(); // triggers re-render in subscribed components
}
}// Program.cs — scoped for Blazor Server (per SignalR circuit), singleton for WASM
builder.Services.AddScoped<AppState>();@inject AppState State
@implements IDisposable
<span class="cart-count">@State.CartItemCount</span>
@code {
protected override void OnInitialized()
{
State.OnChange += StateHasChanged;
}
public void Dispose()
{
State.OnChange -= StateHasChanged;
}
}PersistentComponentState (SSR → interactive handoff)
When a page renders as SSR and then becomes interactive, data fetched during SSR would be re-fetched. Use PersistentComponentState to pass SSR data to the interactive component:
@inject PersistentComponentState ApplicationState
@code {
private List<ProductDto>? _products;
private PersistingComponentStateSubscription _persistSubscription;
protected override async Task OnInitializedAsync()
{
_persistSubscription = ApplicationState.RegisterOnPersisting(Persist);
if (!ApplicationState.TryTakeFromJson<List<ProductDto>>("products", out _products))
{
_products = await ProductService.GetAllAsync();
}
}
private Task Persist()
{
ApplicationState.PersistAsJson("products", _products);
return Task.CompletedTask;
}
public void Dispose() => _persistSubscription.Dispose();
}5. JavaScript Interop
For browser APIs that have no C# equivalent (clipboard, geolocation, Canvas, third-party JS libraries):
// Services/ClipboardService.cs
public class ClipboardService
{
private readonly IJSRuntime _js;
public ClipboardService(IJSRuntime js) => _js = js;
public ValueTask CopyAsync(string text) =>
_js.InvokeVoidAsync("navigator.clipboard.writeText", text);
public ValueTask<string> PasteAsync() =>
_js.InvokeAsync<string>("navigator.clipboard.readText");
}@inject ClipboardService Clipboard
<button @onclick="Copy">Copy to Clipboard</button>
@code {
private async Task Copy()
{
await Clipboard.CopyAsync("https://example.com/share/abc123");
}
}JS modules (avoid global scope pollution)
// wwwroot/js/charts.js
export function renderChart(elementId, data) {
const ctx = document.getElementById(elementId);
new Chart(ctx, { type: 'bar', data });
}// Services/ChartService.cs
public class ChartService : IAsyncDisposable
{
private readonly IJSRuntime _js;
private IJSObjectReference? _module;
public ChartService(IJSRuntime js) => _js = js;
public async Task RenderAsync(string elementId, object data)
{
_module ??= await _js.InvokeAsync<IJSObjectReference>(
"import", "./js/charts.js");
await _module.InvokeVoidAsync("renderChart", elementId, data);
}
public async ValueTask DisposeAsync()
{
if (_module is not null)
await _module.DisposeAsync();
}
}Call JS only in OnAfterRenderAsync (with firstRender: true) — the DOM must exist before you can reference element IDs.
6. Authentication
// Program.cs
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.ClientId = builder.Configuration["Auth:ClientId"];
options.ClientSecret = builder.Configuration["Auth:ClientSecret"];
options.ResponseType = "code";
options.SaveTokens = true;
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
});
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorizationCore();@* App.razor *@
<CascadingAuthenticationState>
<Router AppAssembly="typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
</Found>
</Router>
</CascadingAuthenticationState>@* Protect a page *@
@page "/admin"
@attribute [Authorize(Roles = "Admin")]
<h1>Admin Panel</h1>@* Conditional UI based on auth state *@
<AuthorizeView>
<Authorized>
<p>Welcome, @context.User.Identity?.Name!</p>
<a href="/account/logout">Log out</a>
</Authorized>
<NotAuthorized>
<a href="/account/login">Log in</a>
</NotAuthorized>
</AuthorizeView>7. Deployment Patterns
Blazor Server
Requires a persistent server. Deploy as a standard ASP.NET Core app:
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.Web.dll"]SignalR scales horizontally with a Redis backplane:
builder.Services.AddSignalR().AddStackExchangeRedis(
builder.Configuration.GetConnectionString("Redis")!);Blazor WebAssembly (standalone)
Compiles to static files. Host on any CDN:
# GitHub Actions deploy to Azure Static Web Apps
- name: Deploy
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
app_location: "/"
output_location: "wwwroot"Blazor Web App (unified, .NET 9 default)
Server project with optional WebAssembly client project. Deploy the server project as a containerized app — it serves both SSR content and the WASM bundles for interactive pages.
Choosing Between Render Modes
Dashboard or admin tool accessed by authenticated users? → Blazor Server. Direct DB access, no API layer needed, real-time push via SignalR is built in.
Public-facing app where SEO and first paint matter? → Static SSR for content pages, InteractiveAuto for interactive islands (search, cart, filters).
App that must work offline (field technician, point-of-sale)? → Blazor WebAssembly with PWA mode and IndexedDB sync.
Mixed app (most of the real world)? → Blazor Web App with InteractiveAuto. Static SSR pages default to fast server-rendered HTML; interactive components hydrate progressively.
The mistake most teams make is choosing Blazor Server for everything because it's familiar (direct service access) and then discovering that SignalR connection limits become a scaling constraint at ~10K concurrent users. Auto mode avoids this by offloading interaction to WASM once the initial page loads.