Learnixo

.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.

RAZOR
@* 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
C#
@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

RAZOR
@* 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

RAZOR
@rendermode InteractiveServer

UI 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

RAZOR
@* 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

RAZOR
@rendermode InteractiveAuto

First 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

RAZOR
@* 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;
        }
    }
}
C#
// 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:

RAZOR
@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)

RAZOR
@* App.razor or a layout *@
<CascadingValue Value="_currentUser">
    @Body
</CascadingValue>

@code {
    private UserContext? _currentUser;
    protected override async Task OnInitializedAsync()
    {
        _currentUser = await Auth.GetCurrentUserAsync();
    }
}
RAZOR
@* Any deep child *@
@code {
    [CascadingParameter] private UserContext? CurrentUser { get; set; }
}

State service (app-level singleton)

C#
// 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
    }
}
C#
// Program.cs — scoped for Blazor Server (per SignalR circuit), singleton for WASM
builder.Services.AddScoped<AppState>();
RAZOR
@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:

RAZOR
@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):

C#
// 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");
}
RAZOR
@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)

JAVASCRIPT
// wwwroot/js/charts.js
export function renderChart(elementId, data) {
    const ctx = document.getElementById(elementId);
    new Chart(ctx, { type: 'bar', data });
}
C#
// 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

C#
// 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();
RAZOR
@* App.razor *@
<CascadingAuthenticationState>
    <Router AppAssembly="typeof(App).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
                <NotAuthorized>
                    <RedirectToLogin />
                </NotAuthorized>
            </AuthorizeRouteView>
        </Found>
    </Router>
</CascadingAuthenticationState>
RAZOR
@* Protect a page *@
@page "/admin"
@attribute [Authorize(Roles = "Admin")]

<h1>Admin Panel</h1>
RAZOR
@* 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:

DOCKERFILE
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:

C#
builder.Services.AddSignalR().AddStackExchangeRedis(
    builder.Configuration.GetConnectionString("Redis")!);

Blazor WebAssembly (standalone)

Compiles to static files. Host on any CDN:

YAML
# 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.