Blazor in .NET 9 — WebAssembly, Server, and Auto Render Modes
Build production Blazor applications in .NET 9: component model, render modes (Server, WebAssembly, Auto), form handling, state management, JavaScript interop, authentication, and deployment patterns.
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.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.