✨ Hands-On Tutorial

Blazor Web Development: Create Interactive UIs with C# 12

Blazor lets you build interactive web UIs using C# instead of JavaScript. Whether you target WebAssembly for offline-capable apps or Blazor Server for real-time experiences, you write the same component model, enjoy full .NET tooling, and ship faster.

In this tutorial, you'll build a Todo Dashboard from the ground up. You'll learn how components work, how to bind data, handle forms, manage state, call APIs, and deploy production-ready apps. By the end, you'll have practical experience with Blazor .NET 8's new render modes, streaming SSR, QuickGrid, and authentication patterns.

What You’ll Build

The Todo Dashboard evolves from a simple list to a full-featured app with:

  • Interactive components using Razor syntax and C# 12 features
  • Forms with validation leveraging DataAnnotations and FluentValidation
  • State management across components with services and cascading values
  • Authentication & authorization protecting routes and actions
  • Performance optimization with virtualization and streaming rendering

What's New in Blazor for .NET 8

Blazor for .NET 8 brings unified render modes, streaming server-side rendering, and productivity enhancements that make building interactive web UIs faster and more flexible. Here's what makes Blazor .NET 8 compelling:

🚀

Render Modes

Choose per page or component: SSR for static content, InteractiveServer for real-time updates, InteractiveWebAssembly for offline, or Auto for best of both worlds.

🌊

Streaming SSR

Progressively send markup as data becomes available for faster perceived page loads on dashboards and reports with slow data sources.

🧩

Enhanced Components

C# 12 primary constructors for component parameters and collection expressions for list rendering make code more concise and expressive.

🧮

QuickGrid

Built-in data grid with sorting, filtering, pagination, and virtualization out of the box—no third-party libraries needed for common scenarios.

🔐

Identity UI

Streamlined authentication scaffolding with better protected content patterns and improved integration with ASP.NET Core Identity.

Long-Term Support

.NET 8 is an LTS release supported through November 2026. Start with SSR plus interactive islands for optimal UX and performance. Use full WebAssembly when offline capability is required or when you need maximum client-side scalability.

Setup & First Project

You need the .NET 8 SDK to build Blazor applications. The SDK includes templates for both Blazor Server and Blazor WebAssembly projects.

Install .NET 8 SDK

Download from dotnet.microsoft.com/download and verify installation:

Terminal
dotnet --version
# Should show 8.0.x or higher

Create Your First Blazor Project

Create Blazor Server Project
# Blazor Web App (default: SSR with optional interactivity)
dotnet new blazor -n TodoDashboard
cd TodoDashboard
dotnet run

# Blazor WebAssembly (client-side only)
dotnet new blazorwasm -n TodoDashboard.Wasm

# With Auto interactivity (hybrid SSR/interactive)
dotnet new blazor -n TodoDashboard --interactivity Auto

Your app will start on https://localhost:5001. The default template includes sample components demonstrating weather data and counter interactions.

Project Structure

Project Files
TodoDashboard/
├── Components/
│   ├── Layout/
│   │   ├── MainLayout.razor
│   │   └── NavMenu.razor
│   ├── Pages/
│   │   ├── Home.razor
│   │   ├── Counter.razor
│   │   └── Weather.razor
│   ├── _Imports.razor       # Global using statements
│   └── App.razor            # Root component
├── wwwroot/                 # Static assets
├── appsettings.json
├── Program.cs               # App configuration
└── TodoDashboard.csproj

Add QuickGrid Package

For data grids with built-in features, add the QuickGrid package:

Add QuickGrid
dotnet add package Microsoft.AspNetCore.Components.QuickGrid

Configure Render Modes in Program.cs

Program.cs - Render Mode Setup
var builder = WebApplication.CreateBuilder(args);

// Add services
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();

// Map Razor components with render modes
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode()
    .AddInteractiveWebAssemblyRenderMode();

app.Run();
Tip: IDE Options

Visual Studio 2022 (17.8+), Visual Studio Code with C# Dev Kit, and JetBrains Rider (2023.3+) all support Blazor with hot reload, IntelliSense, and component debugging.

Components Fundamentals

Blazor components are reusable UI chunks written in Razor syntax (.razor files). Components combine HTML markup with C# logic and can nest, pass parameters, and respond to events.

Anatomy of a Razor Component

TodoCard.razor - Basic Component
<div class="todo-card">
    <h3>@Title</h3>
    <p>@Description</p>
    <button @onclick="HandleClick">Mark Complete</button>
</div>

@code {
    [Parameter]
    public string Title { get; set; } = string.Empty;
    
    [Parameter]
    public string Description { get; set; } = string.Empty;
    
    [Parameter]
    public EventCallback OnComplete { get; set; }
    
    private async Task HandleClick()
    {
        await OnComplete.InvokeAsync();
    }
}

Component Lifecycle

Blazor components have lifecycle methods you can override for initialization, parameter changes, and rendering:

  • OnInitialized / OnInitializedAsync: Called once when component is first created
  • OnParametersSet / OnParametersSetAsync: Called when parameters are set or changed
  • OnAfterRender / OnAfterRenderAsync: Called after component renders (good for JS interop)
  • StateHasChanged: Manually trigger re-render when state changes outside event handlers
Component Lifecycle Example
@page "/todo/{Id:int}"
@inject ITodoService TodoService

<h2>@todo?.Title</h2>
<p>@todo?.Description</p>

@code {
    [Parameter]
    public int Id { get; set; }
    
    private TodoItem? todo;
    
    protected override async Task OnInitializedAsync()
    {
        // Load data once on initialization
        todo = await TodoService.GetByIdAsync(Id);
    }
    
    protected override async Task OnParametersSetAsync()
    {
        // Reload if Id parameter changes
        if (Id > 0)
        {
            todo = await TodoService.GetByIdAsync(Id);
        }
    }
    
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            // JS interop safe here - DOM is ready
            await JSRuntime.InvokeVoidAsync("scrollToTop");
        }
    }
}

Primary Constructors for Component Parameters (C# 12)

C# 12 primary constructors work beautifully with component parameters for cleaner code:

Without Primary Constructor
@code {
    [Inject]
    private ITodoService TodoService { get; set; } = default!;
    
    [Inject]
    private ILogger<TodoList> Logger { get; set; } = default!;
    
    [Parameter]
    public string Filter { get; set; } = "all";
    
    private List<TodoItem> todos = new();
    
    protected override async Task OnInitializedAsync()
    {
        todos = await TodoService.GetAllAsync();
        Logger.LogInformation("Loaded {Count} todos", todos.Count);
    }
}
With Primary Constructor (C# 12)
@inject ITodoService TodoService
@inject ILogger<TodoList> Logger

@code {
    [Parameter]
    public string Filter { get; set; } = "all";
    
    private List<TodoItem> todos = [];
    
    protected override async Task OnInitializedAsync()
    {
        todos = await TodoService.GetAllAsync();
        Logger.LogInformation("Loaded {Count} todos", todos.Count);
    }
}

Razor Directives

  • @page "/route" - Define routable component
  • @inject Service - Inject dependency
  • @using Namespace - Import namespace
  • @attribute [Authorize] - Apply attributes to component
  • @rendermode InteractiveServer - Set component render mode
  • @key - Help Blazor identify elements in lists
Performance Warning

Avoid heavy work in OnAfterRender without guards—it runs on every render. Use the firstRender parameter to execute logic only once. Use StateHasChanged() sparingly; Blazor automatically re-renders after event handlers complete.

Data Binding & Forms

Blazor provides declarative data binding with @bind for two-way synchronization between UI and C# properties. Forms include validation with DataAnnotations or FluentValidation.

One-Way and Two-Way Binding

Data Binding Examples
@page "/binding-demo"

<h3>Current Value: @currentValue</h3>

<!-- Two-way binding -->
<input @bind="currentValue" />

<!-- Two-way binding with event -->
<input @bind="currentValue" @bind:event="oninput" />

<!-- Formatted binding -->
<input type="date" @bind="selectedDate" @bind:format="yyyy-MM-dd" />

<!-- Controlled binding (C# 12 style) -->
<input value="@controlledValue" 
       @oninput="@(e => controlledValue = e.Value?.ToString() ?? "")" />

@code {
    private string currentValue = "";
    private DateTime selectedDate = DateTime.Today;
    private string controlledValue = "";
}

EditForm and Validation

TodoForm.razor - Form with Validation
@page "/todo/create"
@inject ITodoService TodoService
@inject NavigationManager Navigation

<h2>Create Todo</h2>

<EditForm Model="@model" OnValidSubmit="HandleSubmit">
    <DataAnnotationsValidator />
    <ValidationSummary />
    
    <div class="form-group">
        <label>Title:</label>
        <InputText @bind-Value="model.Title" class="form-control" />
        <ValidationMessage For="@(() => model.Title)" />
    </div>
    
    <div class="form-group">
        <label>Description:</label>
        <InputTextArea @bind-Value="model.Description" class="form-control" />
        <ValidationMessage For="@(() => model.Description)" />
    </div>
    
    <div class="form-group">
        <label>Priority:</label>
        <InputSelect @bind-Value="model.Priority" class="form-control">
            <option value="Low">Low</option>
            <option value="Medium">Medium</option>
            <option value="High">High</option>
        </InputSelect>
    </div>
    
    <div class="form-group">
        <label>
            <InputCheckbox @bind-Value="model.IsUrgent" />
            Mark as Urgent
        </label>
    </div>
    
    <button type="submit" class="btn btn-primary">Create</button>
    <button type="button" class="btn btn-secondary" @onclick="Cancel">Cancel</button>
</EditForm>

@code {
    private TodoCreateModel model = new();
    
    private async Task HandleSubmit()
    {
        await TodoService.CreateAsync(model);
        Navigation.NavigateTo("/todos");
    }
    
    private void Cancel()
    {
        Navigation.NavigateTo("/todos");
    }
}

public class TodoCreateModel
{
    [Required]
    [StringLength(100, MinimumLength = 3)]
    public string Title { get; set; } = string.Empty;
    
    [StringLength(500)]
    public string? Description { get; set; }
    
    [Required]
    public string Priority { get; set; } = "Medium";
    
    public bool IsUrgent { get; set; }
}

Custom Validation with FluentValidation

For complex validation rules, use FluentValidation:

FluentValidation Example
// Install: dotnet add package FluentValidation
using FluentValidation;

public class TodoCreateModelValidator : AbstractValidator<TodoCreateModel>
{
    public TodoCreateModelValidator()
    {
        RuleFor(x => x.Title)
            .NotEmpty().WithMessage("Title is required")
            .Length(3, 100).WithMessage("Title must be 3-100 characters")
            .Must(NotContainBadWords).WithMessage("Title contains inappropriate content");
        
        RuleFor(x => x.Description)
            .MaximumLength(500).WithMessage("Description too long");
        
        RuleFor(x => x.Priority)
            .Must(p => new[] { "Low", "Medium", "High" }.Contains(p))
            .WithMessage("Invalid priority");
    }
    
    private bool NotContainBadWords(string title)
    {
        var badWords = new[] { "spam", "test" };
        return !badWords.Any(word => title.Contains(word, StringComparison.OrdinalIgnoreCase));
    }
}
Tip: Prefer Record Models

Use C# 12 record types with validation attributes for form models. Records provide value semantics and immutability by default, making state management clearer. For controlled inputs, use @bind:get and @bind:set directives for fine-grained control.

Routing & Navigation

Blazor routing maps URLs to components using the @page directive. The router supports parameters, constraints, query strings, and programmatic navigation.

Route Parameters and Constraints

Route Examples
@* Simple route *@
@page "/todos"

@* Route with parameter *@
@page "/todo/{id:int}"

@* Optional parameter *@
@page "/search/{query?}"

@* Multiple constraints *@
@page "/posts/{year:int:min(2020)}/{month:int:range(1,12)}"

@* Catch-all parameter *@
@page "/docs/{*path}"

@code {
    [Parameter]
    public int Id { get; set; }
    
    [Parameter]
    public string? Query { get; set; }
    
    [Parameter]
    public int Year { get; set; }
    
    [Parameter]
    public int Month { get; set; }
    
    [Parameter]
    public string? Path { get; set; }
}

Query String Parameters

Query String Binding
@page "/search"
@inject NavigationManager Navigation

<h2>Search Results</h2>

<input @bind="searchQuery" @bind:event="oninput" />
<button @onclick="Search">Search</button>

<p>Query: @Query</p>
<p>Page: @Page</p>

@code {
    [Parameter]
    [SupplyParameterFromQuery]
    public string? Query { get; set; }
    
    [Parameter]
    [SupplyParameterFromQuery(Name = "page")]
    public int Page { get; set; } = 1;
    
    private string searchQuery = "";
    
    private void Search()
    {
        Navigation.NavigateTo($"/search?query={searchQuery}&page=1");
    }
}

Programmatic Navigation

NavigationManager Usage
@inject NavigationManager Navigation

<button @onclick="NavigateToTodos">View Todos</button>
<button @onclick="NavigateToDetail">View Detail</button>
<button @onclick="GoBack">Go Back</button>

@code {
    private void NavigateToTodos()
    {
        Navigation.NavigateTo("/todos");
    }
    
    private void NavigateToDetail()
    {
        Navigation.NavigateTo($"/todo/{42}");
    }
    
    private void GoBack()
    {
        Navigation.NavigateTo("/", replace: true);
    }
    
    protected override void OnInitialized()
    {
        // Get current URL
        var uri = Navigation.Uri;
        var baseUri = Navigation.BaseUri;
        
        // Listen to location changes
        Navigation.LocationChanged += HandleLocationChanged;
    }
    
    private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
    {
        Console.WriteLine($"Navigated to {e.Location}");
    }
    
    public void Dispose()
    {
        Navigation.LocationChanged -= HandleLocationChanged;
    }
}

Layouts and NotFound Handling

App.razor - Router Configuration
<Router AppAssembly="@typeof(App).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        <FocusOnNavigate RouteData="@routeData" Selector="h1" />
    </Found>
    <NotFound>
        <PageTitle>Not Found</PageTitle>
        <LayoutView Layout="@typeof(MainLayout)">
            <div class="not-found">
                <h1>404 - Page Not Found</h1>
                <p>The page you're looking for doesn't exist.</p>
                <a href="/">Return to Home</a>
            </div>
        </LayoutView>
    </NotFound>
</Router>
SSR and Hydration

With SSR render mode, the first load is server-rendered HTML. Interactive render modes (Server or WebAssembly) then hydrate the page, enabling client-side navigation. This provides fast initial loads with SPA-like navigation after hydration completes.

State Management

State management in Blazor ranges from local component state to shared application state. Choose the right approach based on scope and lifetime requirements.

Local Component State

Local State Example
@page "/counter"

<h2>Counter: @count</h2>

<button @onclick="Increment">Increment</button>
<button @onclick="Decrement">Decrement</button>
<button @onclick="Reset">Reset</button>

@code {
    private int count = 0;
    
    private void Increment() => count++;
    private void Decrement() => count--;
    private void Reset() => count = 0;
}

Cascading Parameters

Cascading values pass data down the component tree without explicit parameters at each level:

Cascading Values
@* MainLayout.razor *@
<CascadingValue Value="@currentTheme">
    @Body
</CascadingValue>

@code {
    private string currentTheme = "light";
}

@* Child component anywhere in the tree *@
@code {
    [CascadingParameter]
    public string Theme { get; set; } = "light";
}

State Service with Dependency Injection

TodoStateService.cs
public class TodoStateService
{
    private List<TodoItem> _todos = [];
    
    public event Action? OnChange;
    
    public IReadOnlyList<TodoItem> Todos => _todos.AsReadOnly();
    
    public void AddTodo(TodoItem todo)
    {
        _todos.Add(todo);
        NotifyStateChanged();
    }
    
    public void RemoveTodo(int id)
    {
        var todo = _todos.FirstOrDefault(t => t.Id == id);
        if (todo != null)
        {
            _todos.Remove(todo);
            NotifyStateChanged();
        }
    }
    
    public void UpdateTodo(TodoItem updatedTodo)
    {
        var index = _todos.FindIndex(t => t.Id == updatedTodo.Id);
        if (index != -1)
        {
            _todos[index] = updatedTodo;
            NotifyStateChanged();
        }
    }
    
    private void NotifyStateChanged() => OnChange?.Invoke();
}

// Register in Program.cs
builder.Services.AddScoped<TodoStateService>();

Using State Service in Components

Component Using State Service
@page "/todos"
@inject TodoStateService StateService
@implements IDisposable

<h2>Todos (@StateService.Todos.Count)</h2>

@foreach (var todo in StateService.Todos)
{
    <div class="todo-item">
        <span>@todo.Title</span>
        <button @onclick="() => Delete(todo.Id)">Delete</button>
    </div>
}

@code {
    protected override void OnInitialized()
    {
        StateService.OnChange += StateHasChanged;
    }
    
    private void Delete(int id)
    {
        StateService.RemoveTodo(id);
    }
    
    public void Dispose()
    {
        StateService.OnChange -= StateHasChanged;
    }
}

State Management Patterns

  • Local state: Component-only data that doesn't need sharing
  • Cascading values: Theme, user context, or configuration flowing down the tree
  • Scoped services: Per-user state in Server apps; per-circuit lifetime
  • Singleton services: Application-wide shared state (use carefully in Server mode)
  • Browser storage: localStorage/sessionStorage via JS interop for persistence
Server Mode Caution

Avoid accidental singleton cross-user state in Blazor Server apps. Use scoped services where identity or user-specific data matters. Singleton services are shared across all users and circuits—only use them for truly global, immutable data.

JavaScript Interop

Most Blazor apps don't need JavaScript, but when you do need browser APIs or third-party libraries, JavaScript interop provides the bridge. Use it sparingly and wrap it in services for testability.

Calling JavaScript from C#

Basic JS Interop
@page "/interop-demo"
@inject IJSRuntime JS

<button @onclick="ShowAlert">Show Alert</button>
<button @onclick="CopyText">Copy to Clipboard</button>
<button @onclick="GetScreenSize">Get Screen Size</button>

<p>@message</p>

@code {
    private string message = "";
    
    private async Task ShowAlert()
    {
        await JS.InvokeVoidAsync("alert", "Hello from Blazor!");
    }
    
    private async Task CopyText()
    {
        await JS.InvokeVoidAsync("navigator.clipboard.writeText", "Copied text");
        message = "Text copied to clipboard";
    }
    
    private async Task GetScreenSize()
    {
        var width = await JS.InvokeAsync<int>("eval", "window.innerWidth");
        var height = await JS.InvokeAsync<int>("eval", "window.innerHeight");
        message = $"Screen: {width}x{height}";
    }
}

JavaScript Modules (Isolated JS)

wwwroot/js/clipboard.js
export function copyToClipboard(text) {
    return navigator.clipboard.writeText(text);
}

export function readFromClipboard() {
    return navigator.clipboard.readText();
}

export function showNotification(message, duration = 3000) {
    const notification = document.createElement('div');
    notification.className = 'notification';
    notification.textContent = message;
    document.body.appendChild(notification);
    
    setTimeout(() => {
        notification.remove();
    }, duration);
}
CopyToClipboard.razor
@inject IJSRuntime JS
@implements IAsyncDisposable

<button @onclick="Copy">Copy: @TextToCopy</button>

@code {
    [Parameter]
    public string TextToCopy { get; set; } = "";
    
    private IJSObjectReference? module;
    
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            module = await JS.InvokeAsync<IJSObjectReference>(
                "import", "./js/clipboard.js");
        }
    }
    
    private async Task Copy()
    {
        if (module is not null)
        {
            await module.InvokeVoidAsync("copyToClipboard", TextToCopy);
            await module.InvokeVoidAsync("showNotification", "Copied!");
        }
    }
    
    public async ValueTask DisposeAsync()
    {
        if (module is not null)
        {
            await module.DisposeAsync();
        }
    }
}

Calling C# from JavaScript

DotNetObjectReference
@inject IJSRuntime JS
@implements IDisposable

<div id="map"></div>

@code {
    private DotNetObjectReference<MapComponent>? objRef;
    
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            objRef = DotNetObjectReference.Create(this);
            await JS.InvokeVoidAsync("initMap", "map", objRef);
        }
    }
    
    [JSInvokable]
    public void OnMapClick(double lat, double lng)
    {
        Console.WriteLine($"Map clicked at {lat}, {lng}");
        StateHasChanged();
    }
    
    public void Dispose()
    {
        objRef?.Dispose();
    }
}

// In JavaScript:
// function initMap(elementId, dotnetHelper) {
//     map.on('click', (e) => {
//         dotnetHelper.invokeMethodAsync('OnMapClick', e.latlng.lat, e.latlng.lng);
//     });
// }
Tip: Prefer Built-In Components

Before reaching for JS interop, check if Blazor has a built-in component. Use <InputFile> for file uploads, <NavigationManager> for routing, and <AuthorizeView> for auth. Wrap interop in services for easier unit testing.

Authentication & Authorization

Blazor integrates with ASP.NET Core Identity for Server apps and supports token-based authentication for WebAssembly apps calling backend APIs.

Server-Side Authentication Setup

Program.cs - Identity Setup
using Microsoft.AspNetCore.Identity;

var builder = WebApplication.CreateBuilder(args);

// Add authentication and authorization
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();

// Add Identity (if using database)
builder.Services.AddIdentity<IdentityUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run();

Protecting Components

Protected Component
@page "/dashboard"
@attribute [Authorize]

<h2>Dashboard</h2>
<p>Welcome, @context.User.Identity?.Name</p>

<AuthorizeView>
    <Authorized>
        <p>You are logged in.</p>
    </Authorized>
    <NotAuthorized>
        <p>Please log in.</p>
    </NotAuthorized>
</AuthorizeView>

<AuthorizeView Roles="Admin">
    <Authorized>
        <button>Admin Action</button>
    </Authorized>
</AuthorizeView>

<AuthorizeView Policy="RequireManagerRole">
    <Authorized>
        <p>Manager content</p>
    </Authorized>
</AuthorizeView>

Custom Authorization with Claims

Claims-Based Authorization
// Register policy in Program.cs
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("RequireManagerRole", policy =>
        policy.RequireRole("Manager"));
    
    options.AddPolicy("CanEditTodos", policy =>
        policy.RequireClaim("Permission", "Edit"));
    
    options.AddPolicy("SeniorEmployee", policy =>
        policy.RequireAssertion(context =>
            context.User.HasClaim(c => 
                c.Type == "EmployeeLevel" && 
                int.Parse(c.Value) >= 5)));
});

WebAssembly Authentication

WASM Auth Setup
// Install: Microsoft.AspNetCore.Components.WebAssembly.Authentication

var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.Services.AddHttpClient("TodoApi", client =>
    client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
    .AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();

builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>()
    .CreateClient("TodoApi"));

// OIDC authentication
builder.Services.AddOidcAuthentication(options =>
{
    builder.Configuration.Bind("Auth0", options.ProviderOptions);
    options.ProviderOptions.ResponseType = "code";
});

await builder.Build().RunAsync();

Login Flow Component

Login.razor
@page "/login"
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthStateProvider

<EditForm Model="@model" OnValidSubmit="HandleLogin">
    <DataAnnotationsValidator />
    
    <div class="form-group">
        <label>Email:</label>
        <InputText @bind-Value="model.Email" class="form-control" />
        <ValidationMessage For="@(() => model.Email)" />
    </div>
    
    <div class="form-group">
        <label>Password:</label>
        <InputText @bind-Value="model.Password" type="password" class="form-control" />
        <ValidationMessage For="@(() => model.Password)" />
    </div>
    
    <button type="submit" class="btn btn-primary">Login</button>
    
    @if (!string.IsNullOrEmpty(errorMessage))
    {
        <div class="alert alert-danger">@errorMessage</div>
    }
</EditForm>

@code {
    private LoginModel model = new();
    private string errorMessage = "";
    
    private async Task HandleLogin()
    {
        // Authenticate user (implement with your auth service)
        var success = await AuthenticateUser(model.Email, model.Password);
        
        if (success)
        {
            Navigation.NavigateTo("/");
        }
        else
        {
            errorMessage = "Invalid credentials";
        }
    }
    
    private Task<bool> AuthenticateUser(string email, string password)
    {
        // Implement authentication logic
        return Task.FromResult(true);
    }
}

public class LoginModel
{
    [Required, EmailAddress]
    public string Email { get; set; } = "";
    
    [Required]
    public string Password { get; set; } = "";
}
Tip: OAuth for WebAssembly

For WebAssembly apps, use the Microsoft.AspNetCore.Components.WebAssembly.Authentication package templates to wire OAuth quickly. Configure your identity provider (Auth0, Azure AD, IdentityServer) and let the package handle token management, refresh, and protected API calls.

Advanced Patterns: Render Modes & Performance

Blazor .NET 8 introduces flexible render modes that let you choose how each component executes. Combined with streaming rendering and performance optimizations, you can build fast, scalable applications.

Understanding Render Modes

Static SSR
@page "/products"
@* No render mode = Static SSR (default) *@

<h2>Products</h2>

@foreach (var product in products)
{
    <div class="product">
        <h3>@product.Name</h3>
        <p>$@product.Price</p>
    </div>
}

@code {
    private List<Product> products = [];
    
    protected override async Task OnInitializedAsync()
    {
        products = await GetProductsAsync();
    }
}

// Best for: SEO-friendly content, static pages, initial page loads
InteractiveServer
@page "/dashboard"
@rendermode InteractiveServer

<h2>Live Dashboard</h2>
<p>Updates: @updateCount</p>

@code {
    private int updateCount = 0;
    private Timer? timer;
    
    protected override void OnInitialized()
    {
        timer = new Timer(_ =>
        {
            updateCount++;
            InvokeAsync(StateHasChanged);
        }, null, 1000, 1000);
    }
}

// Best for: Real-time updates, low-latency interactions, internal apps
InteractiveWebAssembly
@page "/editor"
@rendermode InteractiveWebAssembly

<h2>Offline Editor</h2>
<textarea @bind="content" @bind:event="oninput"></textarea>
<p>Characters: @content.Length</p>

@code {
    private string content = "";
}

// Best for: Offline capability, high user scalability, client-heavy apps
Auto Mode
@page "/hybrid"
@rendermode InteractiveAuto

<h2>Hybrid Component</h2>
<button @onclick="Increment">Count: @count</button>

@code {
    private int count = 0;
    private void Increment() => count++;
}

// Best for: Best of both worlds—SSR first load, then interactive based on availability

Render Mode Selection Guidance

  • Static SSR: Marketing pages, blogs, product catalogs—anything that benefits from SEO and doesn't need interactivity
  • InteractiveServer: Admin dashboards, internal tools, real-time apps with low user count
  • InteractiveWebAssembly: Offline-capable apps, PWAs, apps with thousands of concurrent users
  • Auto: E-commerce, SaaS apps—fast initial load with interactive features

Streaming Rendering

Streaming SSR Example
@page "/report"
@attribute [StreamRendering(true)]

<h2>Sales Report</h2>

@if (salesData == null)
{
    <p>Loading...</p>
}
else
{
    <table>
        @foreach (var item in salesData)
        {
            <tr>
                <td>@item.Product</td>
                <td>$@item.Revenue</td>
            </tr>
        }
    </table>
}

@code {
    private List<SalesItem>? salesData;
    
    protected override async Task OnInitializedAsync()
    {
        // Simulate slow data source
        await Task.Delay(2000);
        salesData = await FetchSalesDataAsync();
    }
}
Streaming and SEO

Streaming sends initial markup quickly, then streams updates as data arrives. This improves perceived performance but may affect SEO crawlers that don't wait for streaming content. Test with Google Search Console and consider SSR without streaming for critical SEO pages.

QuickGrid for Data Tables

QuickGrid Example
@page "/users"
@using Microsoft.AspNetCore.Components.QuickGrid

<h2>User List</h2>

<QuickGrid Items="@users" Pagination="@pagination">
    <PropertyColumn Property="@(u => u.Name)" Sortable="true" />
    <PropertyColumn Property="@(u => u.Email)" Sortable="true" />
    <PropertyColumn Property="@(u => u.Role)" />
    <TemplateColumn Title="Actions">
        <button @onclick="() => Edit(context)">Edit</button>
        <button @onclick="() => Delete(context.Id)">Delete</button>
    </TemplateColumn>
</QuickGrid>

<Paginator State="@pagination" />

@code {
    private IQueryable<User> users = new List<User>().AsQueryable();
    private PaginationState pagination = new() { ItemsPerPage = 10 };
    
    protected override async Task OnInitializedAsync()
    {
        var allUsers = await GetUsersAsync();
        users = allUsers.AsQueryable();
    }
}

Performance Best Practices

  1. Minimize re-renders: Use ShouldRender() override to control when components update
  2. Use @key directive: Help Blazor identify list items efficiently for minimal DOM updates
  3. Split components: Smaller components render faster; isolate expensive rendering logic
  4. Cache data: Store API responses in memory cache or browser storage
  5. Virtualization: Use <Virtualize> for large lists to render only visible items
  6. Lazy loading: Load assemblies and components on demand in WebAssembly apps
  7. Measure performance: Use browser DevTools and dotnet-trace to identify bottlenecks
Virtualization Example
<Virtualize Items="@items" Context="item">
    <div class="item">
        <h4>@item.Title</h4>
        <p>@item.Description</p>
    </div>
</Virtualize>

@code {
    private List<Item> items = Enumerable.Range(1, 10000)
        .Select(i => new Item { Title = $"Item {i}" })
        .ToList();
}

Testing Blazor Apps

Testing Blazor components ensures UI behavior and logic work correctly. bUnit provides a testing framework specifically designed for Blazor components.

Setup bUnit

Install bUnit
# Create test project
dotnet new xunit -n TodoDashboard.Tests
cd TodoDashboard.Tests

# Add bUnit
dotnet add package bUnit
dotnet add package bUnit.web

# Add reference to your Blazor project
dotnet add reference ../TodoDashboard/TodoDashboard.csproj

Basic Component Test

CounterTests.cs
using Bunit;
using Xunit;

public class CounterTests : TestContext
{
    [Fact]
    public void Counter_Increments_When_Button_Clicked()
    {
        // Arrange
        var cut = RenderComponent<Counter>();
        
        // Act
        cut.Find("button").Click();
        
        // Assert
        cut.Find("p").TextContent.Should().Contain("1");
    }
    
    [Fact]
    public void Counter_Starts_At_Zero()
    {
        // Arrange
        var cut = RenderComponent<Counter>();
        
        // Assert
        cut.Find("p").TextContent.Should().Contain("0");
    }
}

Testing Forms and Validation

TodoFormTests.cs
using Bunit;
using Xunit;
using Microsoft.Extensions.DependencyInjection;

public class TodoFormTests : TestContext
{
    [Fact]
    public void Form_Shows_Validation_Error_For_Empty_Title()
    {
        // Arrange
        var cut = RenderComponent<TodoForm>();
        
        // Act - try to submit without filling form
        cut.Find("form").Submit();
        
        // Assert
        cut.Find(".validation-message")
            .TextContent.Should().Contain("Title is required");
    }
    
    [Fact]
    public async Task Form_Calls_Service_On_Valid_Submit()
    {
        // Arrange
        var mockService = new Mock<ITodoService>();
        Services.AddSingleton(mockService.Object);
        
        var cut = RenderComponent<TodoForm>();
        
        // Act
        cut.Find("input[name='title']").Change("New Todo");
        cut.Find("form").Submit();
        
        // Assert
        await cut.InvokeAsync(() => { });
        mockService.Verify(s => s.CreateAsync(It.IsAny<TodoItem>()), Times.Once);
    }
}

Mocking Services and HTTP

Service Mocking
using Bunit;
using Xunit;
using Moq;

public class TodoListTests : TestContext
{
    [Fact]
    public void TodoList_Displays_Todos_From_Service()
    {
        // Arrange
        var mockService = new Mock<ITodoService>();
        mockService.Setup(s => s.GetAllAsync())
            .ReturnsAsync(new List<TodoItem>
            {
                new() { Id = 1, Title = "Test Todo 1" },
                new() { Id = 2, Title = "Test Todo 2" }
            });
        
        Services.AddSingleton(mockService.Object);
        
        // Act
        var cut = RenderComponent<TodoList>();
        
        // Assert
        cut.FindAll(".todo-item").Count.Should().Be(2);
        cut.Find(".todo-item").TextContent.Should().Contain("Test Todo 1");
    }
}
Testing Strategy

Focus on testing component behavior, not implementation details. Test user interactions (clicks, form submissions), state changes, and service calls. Run tests in CI with dotnet test for confidence before deployment.

Deployment & Production Practices

Blazor apps deploy to various hosting environments. Server apps run on ASP.NET Core hosts, while WebAssembly apps are static files served from any web server or CDN.

Blazor Server Deployment

Publish Blazor Server
# Publish for production
dotnet publish -c Release -o ./publish

# Publish to folder with self-contained runtime
dotnet publish -c Release -r linux-x64 --self-contained -o ./publish

Blazor WebAssembly Deployment

Publish Blazor WASM with AOT
# Standard WASM publish
dotnet publish -c Release

# With AOT compilation (requires wasm-tools)
dotnet workload install wasm-tools
dotnet publish -c Release -p:RunAOTCompilation=true

Docker Deployment

Dockerfile - Blazor Server
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["TodoDashboard.csproj", "./"]
RUN dotnet restore
COPY . .
RUN dotnet build -c Release -o /app/build

FROM build AS publish
RUN dotnet publish -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "TodoDashboard.dll"]

Azure Deployment

Deploy to Azure
# Blazor Server to App Service
az webapp up --name my-blazor-app --resource-group mygroup --runtime "DOTNET|8.0"

# Blazor WASM to Static Web Apps
az staticwebapp create \
  --name my-blazor-wasm \
  --resource-group mygroup \
  --source https://github.com/user/repo \
  --location "East US" \
  --branch main \
  --app-location "src" \
  --output-location "wwwroot"

Production Configuration

appsettings.Production.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Warning",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Server=prod-server;Database=todos;..."
  },
  "ApplicationInsights": {
    "InstrumentationKey": "your-key-here"
  }
}

Performance Monitoring

Application Insights Setup
// Install: Microsoft.ApplicationInsights.AspNetCore

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddApplicationInsightsTelemetry(options =>
{
    options.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"];
});

var app = builder.Build();
app.Run();
Tip: GitHub Actions for CI/CD

Use GitHub Actions to automate builds and deployments. Enable response compression and cache headers for WASM assets. For Server apps, configure server GC settings and connection limits for optimal performance under load.

Migration from Blazor .NET 7

Upgrading from Blazor .NET 7 to .NET 8 is straightforward. Most component code works unchanged; the main task is adopting the new render mode model.

Migration Checklist

  • ✅ Update .NET SDK to 8.0.x
  • ✅ Update TargetFramework to net8.0
  • ✅ Update all NuGet packages to 8.0.x versions
  • ✅ Run dotnet restore and fix any warnings
  • ✅ Choose render modes for your pages and components
  • ✅ Update authentication wiring if using Identity
  • ✅ Test SSR and streaming behavior
  • ✅ Run performance checks and verify no regressions

Project File Changes

.NET 7 Project File
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="7.0.15" />
  </ItemGroup>
</Project>
.NET 8 Project File
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.0" />
  </ItemGroup>
</Project>

Render Mode Adoption

The biggest change in .NET 8 is the unified render mode model. Update your components to specify their render behavior:

.NET 7 - Implicit Server Mode
@page "/counter"

<h2>Counter</h2>
<button @onclick="Increment">@count</button>

@code {
    private int count = 0;
    private void Increment() => count++;
}
.NET 8 - Explicit Render Mode
@page "/counter"
@rendermode InteractiveServer

<h2>Counter</h2>
<button @onclick="Increment">@count</button>

@code {
    private int count = 0;
    private void Increment() => count++;
}

Breaking Changes

Blazor .NET 8 has minimal breaking changes:

  • Render modes: Interactive components now require explicit @rendermode directive
  • JavaScript interop: Some APIs refined; review IJSRuntime calls
  • Authentication: Identity UI templates updated; verify login flows
  • Routing: Enhanced router may handle edge cases differently
Migration Strategy

Most Blazor .NET 7 component code ports directly to .NET 8. The main work is choosing appropriate render modes for each page and component. Start with SSR for static content and add interactive modes where needed. Test thoroughly, especially authentication and JavaScript interop.

Next Steps & Resources

You've learned Blazor fundamentals. Here's where to continue your journey:

Related Tutorials on dotnet-guide.com

Official Documentation

GitHub Example Projects

Clone the complete Todo Dashboard with basic and advanced implementations:

Clone Repository
git clone https://github.com/dotnet-guide-com/tutorials.git
cd tutorials/blazor

# Run basic Todo Dashboard
cd TodoDashboard.Basic
dotnet run

# Run advanced version (auth + EF Core)
cd ../TodoDashboard.Advanced
dotnet run

Community Resources

  • Blazor Community: blazor.net for news and updates
  • .NET Blog: devblogs.microsoft.com/dotnet
  • Awesome Blazor: Curated list of Blazor resources and components
  • Stack Overflow: Tag questions with blazor and blazor-server-side

Component Libraries

  • MudBlazor: Material Design component library
  • Radzen: Premium component suite with free tier
  • Syncfusion: Enterprise components (commercial)
  • Telerik UI: Comprehensive component set (commercial)

Frequently Asked Questions

Blazor Server vs WebAssembly—how do I choose?

Use Blazor Server for internal apps with low latency requirements and simpler deployment—no need to download the .NET runtime to the browser. Use WebAssembly for client-heavy apps that need offline capability or high user scalability without server connections. Use Auto render mode to get the best of both worlds—SSR on first load with interactive mode based on connection availability and performance characteristics.

What are render modes and which should I use?

Render modes control how components execute. Static SSR is fast and SEO-friendly for content pages. InteractiveServer maintains a SignalR connection for real-time updates. InteractiveWebAssembly runs entirely in the browser with offline support. Auto mode combines SSR for initial load with interactive mode afterward. Start with SSR plus interactive islands for optimal performance—use full interactivity only where needed.

Do I need JavaScript at all with Blazor?

Most Blazor apps don't need JavaScript. Blazor provides built-in components for forms, navigation, file uploads, and authentication. Use JavaScript interop only for browser APIs not exposed to C# (clipboard, localStorage, geolocation) or when integrating third-party JavaScript libraries. Wrap interop calls in services for better testability and maintainability.

How do I secure a WebAssembly app calling my API?

Use the Microsoft.AspNetCore.Components.WebAssembly.Authentication package with OAuth/OIDC. Configure AuthenticationStateProvider, add HttpClient with BaseAddressAuthorizationMessageHandler for automatic token injection, and protect routes with AuthorizeView and [Authorize] attributes. Never trust WebAssembly client-side logic alone—always validate and authorize on the server API.

How do I handle very large lists or tables efficiently?

Use QuickGrid with virtualization for built-in sorting, filtering, and efficient rendering of large datasets. For custom solutions, use the <Virtualize> component to render only visible items in scrollable lists. Load data in pages from the server rather than materializing entire collections in memory. Use the @key directive to help Blazor identify and efficiently update list items.

Can I mix SSR, Server, and WebAssembly in one solution?

Yes. Configure multiple render modes in Program.cs and annotate components with @rendermode directives. You can have SSR pages with InteractiveServer islands for real-time features and even lazy-load WebAssembly components for heavy client-side processing. This hybrid approach optimizes for initial load speed while enabling rich interactivity where needed.

What's the difference between Blazor and Razor Pages?

Razor Pages are server-rendered page-based applications with traditional request/response model—each interaction triggers a full page reload. Blazor uses component-based architecture with interactivity through SignalR (Server) or WebAssembly. Blazor enables SPA-like experiences without JavaScript frameworks. Use Razor Pages for simple CRUD apps with traditional forms. Use Blazor for rich, interactive UIs with client-side state management.

When should I use streaming rendering?

Use streaming SSR for pages with slow data sources (external APIs, complex database queries) where you want to show content progressively rather than waiting for all data. Streaming sends initial markup quickly with loading placeholders, then streams updates as data arrives. Ideal for dashboards and reports. Be aware that streaming content may affect SEO crawlers that don't wait for streamed updates—test with Google Search Console and consider standard SSR for critical SEO pages.

Back to Tutorials