✨ Hands-On Tutorial

Blazor SSR & Interactive Islands: Streaming Rendering, Auto Render Mode & Progressive Enhancement

Your Blazor app loads in 80 milliseconds. Search engines index every product. Users without JavaScript still see content. Interactive features work instantly after the first visit. This isn't magic. It's Server-Side Rendering with interactive islands.

Most Blazor apps choose between SSR for speed or WebAssembly for richness. You don't have to pick one. Mix both in the same page. Render product listings on the server for SEO. Add a shopping cart as an interactive island. Stream slow database queries while showing instant content. This tutorial shows you how.

What You'll Build

You'll build a production-ready e-commerce product catalog that demonstrates every technique:

  • Server-rendered product pages that load fast and rank well in search
  • Interactive shopping cart that works offline with WebAssembly
  • Streaming product reviews that don't block initial page load
  • Auto render mode transitions from Server to WebAssembly seamlessly
  • Progressive enhancement so the site works without JavaScript

Understanding Blazor Render Modes

Blazor .NET 8 gives you four render modes. Each serves a specific purpose. Pick the wrong one and you'll either sacrifice SEO or pay unnecessary performance costs.

Static Server Rendering (SSR)

SSR generates HTML on the server and sends it to the browser. No WebAssembly download. No SignalR connection. Just pure HTML. It's fast, SEO-friendly, and works everywhere. But it's not interactive. Clicking a button requires a full page reload.

Use SSR for product listings, blog posts, documentation, and landing pages. Anything content-heavy where initial load speed and search rankings matter more than interactivity.

SSR Component
@* No rendermode directive = SSR by default *@
@page "/products"

Product Catalog

@foreach (var product in products) {

@product.Name

@product.Price.ToString("C")

View Details
} @code { private Product[] products = Array.Empty(); protected override async Task OnInitializedAsync() { // Runs on server, renders to HTML products = await ProductService.GetProductsAsync(); } }

Interactive Server

InteractiveServer keeps a SignalR WebSocket connection open. When you click a button, the event travels to the server over the wire. The server processes it, calculates the diff, and sends back just the changed HTML. It feels instant for most interactions.

Use InteractiveServer for admin dashboards, real-time data displays, and apps where users stay connected. Don't use it for public-facing sites with thousands of concurrent users. Each connection costs server resources.

Interactive Server Component
@page "/cart"
@rendermode InteractiveServer

Shopping Cart

@foreach (var item in cartItems) {
@item.Name
}

Total: @total.ToString("C")

@code { private List cartItems = new(); private decimal total; private async Task RemoveItem(int id) { // Runs on server, UI updates via SignalR cartItems.RemoveAll(x => x.Id == id); total = cartItems.Sum(x => x.Price); await SaveCartAsync(); } }

Interactive WebAssembly

InteractiveWebAssembly downloads the .NET runtime to the browser (about 1.5MB gzipped). After that, your C# code runs entirely client-side. No server calls for UI updates. It enables offline scenarios and scales to millions of users without server load.

Use WebAssembly for games, design tools, offline apps, and features that need client-side performance. The initial download cost pays off when users interact heavily.

Interactive WebAssembly Component
@page "/product-configurator"
@rendermode InteractiveWebAssembly

Configure Your Product

@code { private int rotation = 0; private string color = "red"; // Runs in browser, zero server round trips private string GetPreviewStyle() => $"transform: rotate({rotation}deg); background: {color};"; }

Auto Render Mode

Auto mode is the smart default. It starts with SSR for the first request. Then it checks: is WebAssembly already downloaded? If yes, use it for offline capability. If no, use InteractiveServer with SignalR. On subsequent visits, WebAssembly is cached, so the app runs offline.

Use Auto mode when you want both fast first loads and offline capability without deciding upfront. It adapts based on what's available.

Auto Render Mode Component
@page "/product/{id:int}"
@rendermode InteractiveAuto

@product.Name

@code { [Parameter] public int Id { get; set; } private Product product = new(); private bool isFavorite; protected override async Task OnInitializedAsync() { // First render: SSR on server // After hydration: runs in browser or server product = await ProductService.GetProductAsync(Id); isFavorite = await FavoritesService.IsFavoriteAsync(Id); } private async Task AddToCart() { // Executes where the component is running await CartService.AddAsync(product); } private void ToggleFavorite() { isFavorite = !isFavorite; // Persists to localStorage if WASM, API if Server } }
Decision Matrix

SSR: Content-first pages, SEO-critical, no interactivity needed. InteractiveServer: Real-time features, admin tools, internal apps. InteractiveWebAssembly: Offline apps, high interactivity, client-heavy logic. Auto: Default for new projects, adapts automatically.

Project Setup & Architecture

Setting up a hybrid Blazor app requires planning. You need separate projects for server components and WebAssembly components, plus a shared library for models. Get the structure wrong and you'll fight the framework.

Create the Solution

Start with the Blazor Web App template configured for interactive rendering. This gives you the correct project structure with all references wired up.

Terminal
dotnet new blazor -o ProductCatalog -int Auto
cd ProductCatalog
dotnet sln create

# Creates three projects:
# ProductCatalog - Server project (SSR + Interactive Server)
# ProductCatalog.Client - WebAssembly project
# ProductCatalog.Shared - Shared models/contracts

Configure Render Modes in Program.cs

The server project hosts everything. Configure which render modes you'll support. This determines what gets sent to the browser.

Program.cs
using ProductCatalog.Client;
using ProductCatalog.Components;

var builder = WebApplication.CreateBuilder(args);

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

// Register your services
builder.Services.AddScoped();
builder.Services.AddScoped();

var app = builder.Build();

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

app.MapRazorComponents()
    .AddInteractiveServerRenderMode()
    .AddInteractiveWebAssemblyRenderMode()
    .AddAdditionalAssemblies(typeof(ProductCatalog.Client._Imports).Assembly);

app.Run();

Architecture Strategy

Here's how to organize components for maximum reuse and performance:

  • Server project: SSR pages (product listings, product details, static content)
  • Client project: Interactive components (cart, configurators, real-time features)
  • Shared project: Models, interfaces, DTOs

Components in the server project can use database contexts directly. Components in the client project must call APIs. Shared components work in both but have limited capabilities.

Project References

Server project references Client project and Shared project. Client project references only Shared project. Never reference server code from client code. It won't compile for WebAssembly.

Service Registration Pattern

Services need different implementations for server vs client. Use interfaces to abstract the difference.

Service Registration
// Shared/IProductService.cs
public interface IProductService
{
    Task GetProductsAsync();
    Task GetProductAsync(int id);
}

// Server/Services/ProductService.cs
public class ProductService : IProductService
{
    private readonly AppDbContext _db;

    public ProductService(AppDbContext db) => _db = db;

    public async Task GetProductsAsync()
    {
        // Direct database access on server
        return await _db.Products.ToArrayAsync();
    }
}

// Client/Services/ProductService.cs
public class ProductService : IProductService
{
    private readonly HttpClient _http;

    public ProductService(HttpClient http) => _http = http;

    public async Task GetProductsAsync()
    {
        // API call from WebAssembly
        return await _http.GetFromJsonAsync("/api/products")
            ?? Array.Empty();
    }
}

Streaming SSR Fundamentals

Streaming lets you send HTML in chunks. The browser shows content immediately while your server fetches slower data. Users see something useful in 50ms instead of waiting 2 seconds for everything.

Basic Streaming Pattern

Mark async operations that should stream with the @attribute [StreamRendering(true)] directive in .NET 8. This enables streaming rendering for the component. Blazor renders the page twice: the first pass sends instant content, and the second pass patches in the data when the async work completes. In .NET 9 or later, you can omit the true parameter because it becomes the default.

Streaming Component
@page "/product/{id:int}"
@attribute [StreamRendering(true)]

@product.Name

@product.Description

@if (reviews == null) {
Loading reviews...
} else { @foreach (var review in reviews) {
@review.Author

@review.Content

} } @code { [Parameter] public int Id { get; set; } private Product product = new(); private Review[]? reviews; protected override async Task OnInitializedAsync() { // Loads fast, renders immediately product = await ProductService.GetProductAsync(Id); // Loads slow, streams later reviews = await ReviewService.GetReviewsAsync(Id); } }

Skeleton UI for Loading States

Don't show "Loading..." text. Show skeleton placeholders that match the content shape. It looks more polished and reduces perceived wait time.

Skeleton Loading
@if (products == null)
{
    
@for (int i = 0; i < 12; i++) {
}
} else {
@foreach (var product in products) { }
}

Progressive Data Loading

Load critical data first, then progressive enhance with secondary data. Product details load instantly. Related products stream in after. Reviews come last.

Progressive Loading
protected override async Task OnInitializedAsync()
{
    // Priority 1: Essential data (renders first)
    product = await ProductService.GetProductAsync(Id);
    StateHasChanged(); // Force render

    // Priority 2: Important but not blocking
    var relatedTask = ProductService.GetRelatedAsync(Id);
    var reviewsTask = ReviewService.GetReviewsAsync(Id);

    await Task.WhenAll(relatedTask, reviewsTask);

    relatedProducts = await relatedTask;
    reviews = await reviewsTask;
    // Blazor automatically renders after this
}
Streaming Gotchas

Form submissions don't work during streaming. User input gets lost when the second render happens. Use interactive islands for forms. Streaming also breaks if you have layout-dependent JavaScript that runs before content arrives.

Interactive Islands Pattern

The islands architecture gives you the best of both worlds. Server-render the page for speed and SEO. Add small interactive components where you need them. The page loads fast, search engines see everything, and interactive bits work instantly.

Creating an Interactive Island

Any component can be an island. Add a render mode attribute and it becomes interactive while the rest of the page stays static.

Island Component
@* ProductPage.razor - SSR page *@
@page "/product/{id:int}"

@product.Name

@product.Name

@product.Description

@code { [Parameter] public int Id { get; set; } private Product product = new(); protected override async Task OnInitializedAsync() { product = await ProductService.GetProductAsync(Id); } }
AddToCartButton.razor
@* Runs as interactive island *@


@code {
    [Parameter] public int ProductId { get; set; }
    [Inject] private ICartService CartService { get; set; } = default!;

    private bool isAdding;
    private bool isAdded;

    private async Task AddToCart()
    {
        isAdding = true;
        await CartService.AddAsync(ProductId);
        isAdding = false;
        isAdded = true;

        await Task.Delay(2000);
        isAdded = false;
    }
}

Component Hydration

When an interactive island loads, it goes through hydration. The server sends the initial HTML. JavaScript takes over and makes it interactive. This process needs to be fast or users notice lag.

Keep island components small. Big components take longer to hydrate. Split functionality into multiple islands if one gets too large.

Passing Data to Islands

Islands can receive parameters from their SSR parent. But those parameters must be JSON-serializable. No passing database contexts or complex objects.

Data Passing
@* Parent SSR component *@


@code {
    // Simple types and arrays serialize fine
    private Product product = new();
    private Review[] reviews = Array.Empty();
}

@* ProductReviews.razor - Interactive island *@

Reviews for @ProductName

@foreach (var review in currentReviews) { }
@code { [Parameter] public int ProductId { get; set; } [Parameter] public string ProductName { get; set; } = ""; [Parameter] public Review[] InitialReviews { get; set; } = Array.Empty(); private List currentReviews = new(); private int page = 1; protected override void OnInitialized() { currentReviews.AddRange(InitialReviews); } private async Task LoadMore() { page++; var more = await ReviewService.GetPageAsync(ProductId, page); currentReviews.AddRange(more); } }
Islands Best Practices

Use InteractiveServer for islands that need server data access. Use InteractiveWebAssembly for islands that work offline. Avoid mixing modes unnecessarily. Keep islands focused on one task. Multiple small islands perform better than one large island.

Auto Render Mode Deep Dive

Auto mode sounds simple but the mechanics are subtle. Understanding how it decides between Server and WebAssembly helps you build apps that adapt correctly.

How Auto Mode Works

First request: SSR generates initial HTML. After HTML arrives, JavaScript checks if the WebAssembly runtime is downloaded. If yes, hydrate as WebAssembly. If no, connect via SignalR for InteractiveServer. The choice persists in browser storage.

Second visit: WebAssembly is cached, so the app hydrates as WebAssembly immediately. No server round trips for UI updates. The app works offline.

Auto Mode Component
@page "/cart"
@rendermode InteractiveAuto

Shopping Cart

@if (items == null) {

Loading your cart...

} else if (!items.Any()) {

Your cart is empty.

} else {
@foreach (var item in items) {
@item.Name

@item.Name

@item.Price.ToString("C")

}
Total: @GetTotal().ToString("C")
} @code { [Inject] private ICartService CartService { get; set; } = default!; [Inject] private NavigationManager Nav { get; set; } = default!; private List? items; protected override async Task OnInitializedAsync() { // First render: SSR // After hydration: runs in current mode (Server or WASM) items = await CartService.GetItemsAsync(); } private async Task RemoveItem(int id) { items?.RemoveAll(x => x.Id == id); await CartService.SaveAsync(items ?? new List()); } private decimal GetTotal() { return items?.Sum(x => x.Price * x.Quantity) ?? 0; } private void Checkout() { Nav.NavigateTo("/checkout"); } }

State Preservation During Handoff

When Auto mode switches from InteractiveServer to WebAssembly on subsequent visits, component state doesn't transfer automatically. You need to persist state explicitly.

State Persistence
@inject IJSRuntime JS

private async Task SaveState()
{
    var state = new
    {
        CartItems = items,
        LastUpdated = DateTime.Now
    };

    await JS.InvokeVoidAsync("localStorage.setItem",
        "cart-state",
        JsonSerializer.Serialize(state));
}

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        // Try to restore state from previous session
        var json = await JS.InvokeAsync(
            "localStorage.getItem",
            "cart-state");

        if (!string.IsNullOrEmpty(json))
        {
            var state = JsonSerializer.Deserialize(json);
            items = state?.CartItems;
            StateHasChanged();
        }
    }
}

Fallback Behavior

If WebAssembly fails to download (network issues, corporate firewall), Auto mode falls back to InteractiveServer permanently for that session. Your code doesn't know or care. It works the same either way.

Auto Mode Tradeoffs

Auto mode gives you flexibility but increases complexity. First-time visitors pay InteractiveServer costs. Returning visitors get WebAssembly benefits. If you know your users will only visit once (marketing site), skip Auto and use pure SSR with targeted islands.

Data Fetching Strategies

How you fetch data determines your app's performance. Fetch too early and you block rendering. Fetch too late and users see loading spinners. The right strategy depends on render mode and caching.

SSR Prefetch Pattern

With SSR, fetch all data in OnInitializedAsync before rendering. The server waits for data, renders complete HTML, and sends it. No loading states needed.

SSR Data Fetching
@page "/products"

@foreach (var product in products)
{
    
}

@code {
    private Product[] products = Array.Empty();

    protected override async Task OnInitializedAsync()
    {
        // Blocks until data loads, then renders complete page
        products = await ProductService.GetAllAsync();
    }
}

Client Hydration with Loading States

Interactive components can't block rendering. They show immediately with skeleton UI, then update when data arrives. This prevents the blank screen problem.

Client Data Fetching
@rendermode InteractiveWebAssembly

@if (isLoading)
{
    
@for (int i = 0; i < 8; i++) {
}
} else if (error != null) {

Failed to load products: @error

} else {
@foreach (var product in products) { }
} @code { private Product[] products = Array.Empty(); private bool isLoading = true; private string? error; protected override async Task OnInitializedAsync() { await LoadData(); } private async Task LoadData() { isLoading = true; error = null; try { products = await ProductService.GetAllAsync(); } catch (Exception ex) { error = ex.Message; } finally { isLoading = false; } } private Task Retry() => LoadData(); }

Caching Strategies

Cache aggressively in WebAssembly. The data lives in the browser, so repeated visits are instant. Use memory cache with expiration for data that changes occasionally.

Client-Side Caching
public class CachedProductService : IProductService
{
    private readonly HttpClient _http;
    private readonly Dictionary _cache = new();
    private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);

    public CachedProductService(HttpClient http) => _http = http;

    public async Task GetAllAsync()
    {
        const string key = "all-products";

        if (_cache.TryGetValue(key, out var cached) &&
            cached.Expiry > DateTime.Now)
        {
            return (Product[])cached.Data;
        }

        var products = await _http.GetFromJsonAsync("/api/products")
            ?? Array.Empty();

        _cache[key] = (DateTime.Now + _cacheDuration, products);
        return products;
    }

    public async Task GetAsync(int id)
    {
        var key = $"product-{id}";

        if (_cache.TryGetValue(key, out var cached) &&
            cached.Expiry > DateTime.Now)
        {
            return (Product)cached.Data;
        }

        var product = await _http.GetFromJsonAsync($"/api/products/{id}");

        if (product != null)
        {
            _cache[key] = (DateTime.Now + _cacheDuration, product);
        }

        return product;
    }

    public void InvalidateCache()
    {
        _cache.Clear();
    }
}
Caching Gotchas

Cache invalidation is hard. Clear cache when users add items to cart or change data. Don't cache user-specific data in WebAssembly if multiple users share devices. Use session storage instead of memory for sensitive data.

Forms & Validation Across Modes

Forms behave differently in SSR vs interactive modes. SSR forms submit to the server via HTTP POST. Interactive forms use event handlers. Validation works in both, but error handling diverges.

SSR Form Pattern

SSR forms use standard HTML form submission. Blazor enhances them with anti-forgery tokens automatically. Validation runs on the server. Errors return with the re-rendered page.

SSR Form
@page "/contact"
@using Microsoft.AspNetCore.Components.Forms


    
    

    
@if (!string.IsNullOrEmpty(successMessage)) {
@successMessage
} @code { [SupplyParameterFromForm] private ContactModel model { get; set; } = new(); private string? successMessage; private async Task HandleSubmit() { // Runs on server, form posts back await ContactService.SendMessageAsync(model); successMessage = "Thank you! We'll be in touch soon."; model = new(); // Reset form } public class ContactModel { [Required, StringLength(100)] public string Name { get; set; } = ""; [Required, EmailAddress] public string Email { get; set; } = ""; [Required, StringLength(1000)] public string Message { get; set; } = ""; } }

Interactive Form Pattern

Interactive forms validate and submit without page reloads. You control the entire flow with C# event handlers. Show loading states, handle errors, and update UI immediately.

Interactive Form
@rendermode InteractiveServer


    

    
@if (!string.IsNullOrEmpty(errorMessage)) {
@errorMessage
} @code { private LoginModel model = new(); private bool isSubmitting; private string? errorMessage; private async Task HandleSubmit() { isSubmitting = true; errorMessage = null; try { await AuthService.SignInAsync(model.Email, model.Password); Nav.NavigateTo("/"); } catch (Exception ex) { errorMessage = "Invalid email or password."; } finally { isSubmitting = false; } } }

Shared Validation Logic

Put validation attributes on your models in the Shared project. They work identically in SSR and interactive modes. Client-side validation is instant, but always validate on the server too.

Shared Model Validation
// Shared/Models/CheckoutModel.cs
public class CheckoutModel
{
    [Required(ErrorMessage = "Name is required")]
    [StringLength(100, MinimumLength = 2)]
    public string FullName { get; set; } = "";

    [Required]
    [EmailAddress(ErrorMessage = "Invalid email address")]
    public string Email { get; set; } = "";

    [Required]
    [Phone(ErrorMessage = "Invalid phone number")]
    public string Phone { get; set; } = "";

    [Required]
    [StringLength(200, MinimumLength = 5)]
    public string Address { get; set; } = "";

    [Required]
    [RegularExpression(@"^\d{5}$", ErrorMessage = "Invalid ZIP code")]
    public string ZipCode { get; set; } = "";

    [Required]
    [CreditCard(ErrorMessage = "Invalid card number")]
    public string CardNumber { get; set; } = "";

    [Required]
    [Range(1, 12, ErrorMessage = "Month must be between 1 and 12")]
    public int ExpiryMonth { get; set; }

    [Required]
    [Range(2025, 2050, ErrorMessage = "Invalid expiry year")]
    public int ExpiryYear { get; set; }
}
Form Security

SSR forms include anti-forgery tokens automatically. Interactive forms don't need them since they use SignalR or run in-browser. Never trust client-side validation alone. Always validate on the server API for interactive forms that call services.

JavaScript Interop in Hybrid Apps

JavaScript interop lets you call browser APIs and third-party libraries from C#. But SSR components can't access JavaScript. They run on the server before the browser exists. Plan your interop calls carefully.

SSR-Safe JavaScript Pattern

Check if JavaScript is available before calling it. In SSR, JS runtime throws exceptions. Wrap all JavaScript calls in OnAfterRenderAsync to ensure they only run in the browser.

SSR-Safe Interop
@inject IJSRuntime JS

private bool isJavaScriptAvailable;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        try
        {
            // This runs in browser, not during SSR
            await JS.InvokeVoidAsync("localStorage.setItem", "visited", "true");
            isJavaScriptAvailable = true;
            StateHasChanged();
        }
        catch
        {
            // SSR or JavaScript disabled
            isJavaScriptAvailable = false;
        }
    }
}

private async Task SavePreference(string key, string value)
{
    if (!isJavaScriptAvailable) return;

    await JS.InvokeVoidAsync("localStorage.setItem", key, value);
}

Conditional Module Loading

Load JavaScript modules only when needed. This reduces initial bundle size and speeds up first render.

Lazy Module Loading
@implements IAsyncDisposable
@inject IJSRuntime JS

private IJSObjectReference? chartModule;

private async Task InitializeChart()
{
    // Load module only when user interacts
    chartModule ??= await JS.InvokeAsync(
        "import",
        "./js/chart-component.js");

    await chartModule.InvokeVoidAsync("initChart", chartData);
}

public async ValueTask DisposeAsync()
{
    if (chartModule != null)
    {
        await chartModule.DisposeAsync();
    }
}

Module Initialization Pattern

Create a JavaScript module with initialization and cleanup functions. Import it lazily and dispose properly.

chart-component.js
// wwwroot/js/chart-component.js
let chartInstance = null;

export function initChart(elementId, data) {
    const canvas = document.getElementById(elementId);
    if (!canvas) return;

    chartInstance = new Chart(canvas, {
        type: 'line',
        data: data,
        options: {
            responsive: true,
            maintainAspectRatio: false
        }
    });
}

export function updateChart(data) {
    if (chartInstance) {
        chartInstance.data = data;
        chartInstance.update();
    }
}

export function destroyChart() {
    if (chartInstance) {
        chartInstance.destroy();
        chartInstance = null;
    }
}
Interop Performance

JavaScript interop has overhead. Each call crosses the C#/JavaScript boundary. Batch operations when possible. Don't call JavaScript in tight loops. Cache JavaScript object references instead of looking them up repeatedly.

State Management Patterns

State management gets complicated when components run in different modes. SSR components can't share state with interactive components easily. Plan your state architecture upfront.

Cascading Parameters for Shared State

Cascading parameters flow data down the component tree. They work across render modes but have limitations. Changes don't automatically notify children.

Cascading State
@* App.razor or layout *@

    
        
            
                
            
        
    


@code {
    private ThemeService themeService = new();
    private UserService userService = new();
}

@* Any child component *@
@code {
    [CascadingParameter]
    private ThemeService ThemeService { get; set; } = default!;

    [CascadingParameter]
    private UserService UserService { get; set; } = default!;

    private async Task ToggleTheme()
    {
        await ThemeService.ToggleDarkModeAsync();
        // Children won't re-render automatically
        StateHasChanged(); // Required
    }
}

Service-Based State with Events

Register services as scoped or singleton. Components inject the service and subscribe to change events. This works across all render modes.

State Service
// Services/CartStateService.cs
public class CartStateService
{
    private readonly List _items = new();

    public event Action? OnChange;

    public IReadOnlyList Items => _items.AsReadOnly();

    public decimal Total => _items.Sum(x => x.Price * x.Quantity);

    public void AddItem(CartItem item)
    {
        var existing = _items.FirstOrDefault(x => x.ProductId == item.ProductId);
        if (existing != null)
        {
            existing.Quantity++;
        }
        else
        {
            _items.Add(item);
        }
        NotifyStateChanged();
    }

    public void RemoveItem(int productId)
    {
        _items.RemoveAll(x => x.ProductId == productId);
        NotifyStateChanged();
    }

    public void Clear()
    {
        _items.Clear();
        NotifyStateChanged();
    }

    private void NotifyStateChanged() => OnChange?.Invoke();
}

// Program.cs
builder.Services.AddScoped();

// Component usage
@implements IDisposable
@inject CartStateService CartState

🛒 @CartState.Items.Count
@code { protected override void OnInitialized() { CartState.OnChange += StateHasChanged; } public void Dispose() { CartState.OnChange -= StateHasChanged; } }

Circuit Resilience

InteractiveServer components lose state if the SignalR connection drops. Persist critical state to survive reconnection.

Circuit State Persistence
@inject ProtectedSessionStorage Storage

private CartModel cart = new();

protected override async Task OnInitializedAsync()
{
    // Restore state from session storage
    var result = await Storage.GetAsync("cart-state");
    if (result.Success && result.Value != null)
    {
        cart = result.Value;
    }
}

private async Task UpdateCart()
{
    cart.LastModified = DateTime.Now;

    // Persist state for circuit resilience
    await Storage.SetAsync("cart-state", cart);
}

// Handle reconnection
private async Task OnCircuitReconnected()
{
    // Reload critical state
    var result = await Storage.GetAsync("cart-state");
    if (result.Success && result.Value != null)
    {
        cart = result.Value;
        StateHasChanged();
    }
}
State Scoping

Scoped services create one instance per user circuit (InteractiveServer) or per user session (WebAssembly). Singleton services share state across all users. Be careful with singletons in server apps. Use scoped for user-specific state.

Performance Optimization

Performance isn't one number. It's Time to First Byte (TTFB), First Contentful Paint (FCP), Time to Interactive (TTI), and bundle size. Optimize each metric for your use case.

TTFB Optimization

SSR shines here. Server generates HTML fast and sends it immediately. Keep database queries fast, use caching, and avoid synchronous I/O. Target TTFB under 200ms.

Fast SSR with Caching
@page "/products"
@inject IMemoryCache Cache

@foreach (var product in products)
{
    
}

@code {
    private Product[] products = Array.Empty();

    protected override async Task OnInitializedAsync()
    {
        // Try cache first
        if (!Cache.TryGetValue("featured-products", out Product[]? cached))
        {
            // Cache miss: fetch from database
            cached = await ProductService.GetFeaturedAsync();

            // Cache for 5 minutes
            Cache.Set("featured-products", cached, TimeSpan.FromMinutes(5));
        }

        products = cached ?? Array.Empty();
    }
}

Reducing Hydration Cost

Hydration converts static HTML into interactive components. Big components take longer. Split large pages into smaller interactive islands. Only hydrate what needs interactivity.

Minimal Hydration
@* Bad: Entire page interactive *@
@page "/product/{id:int}"
@rendermode InteractiveAuto





@* Good: Only interactive parts *@
@page "/product/{id:int}"




Lazy Loading Components

Don't load WebAssembly components until the user needs them. Use lazy loading with @using directives and load on-demand.

Lazy Component Loading
@if (showConfigurator)
{
    
}
else
{
    
}

@code {
    private bool showConfigurator;

    private void LoadConfigurator()
    {
        // Only downloads WASM when button clicked
        showConfigurator = true;
    }
}

Bundle Size Reduction

WebAssembly downloads the .NET runtime. Reduce bundle size by trimming unused code and using AOT compilation for production.

ProductCatalog.Client.csproj

  
    net8.0

    
    true
    link

    
    true

    
    false
  
Performance Benchmarks

SSR: 80-150ms TTFB, 200-300ms FCP. InteractiveServer: 150-250ms TTFB, 300-500ms TTI (includes SignalR). InteractiveWebAssembly: 100ms TTFB, 800-1500ms TTI first visit (downloads runtime), 100-200ms TTI cached visits.

SEO & Progressive Enhancement

Search engines crawl HTML. If your content needs JavaScript to render, you lose SEO. SSR solves this by sending complete HTML immediately. But you need to handle metadata, structured data, and crawlability correctly.

Dynamic Metadata Management

Set page titles, descriptions, and Open Graph tags dynamically based on content. Use Blazor's HeadContent component to inject metadata into the page head.

Dynamic SEO Metadata
@page "/product/{id:int}"


    @product.Name | ProductCatalog
    
    
    
    
    
    


@product.Name

@product.Name

@product.Description

@code { [Parameter] public int Id { get; set; } private Product product = new(); protected override async Task OnInitializedAsync() { product = await ProductService.GetProductAsync(Id); } }

Structured Data for Rich Results

Add JSON-LD structured data to help search engines understand your content. Products, reviews, and FAQ pages benefit from rich results in search.

Product Structured Data
@page "/product/{id:int}"


    


@code {
    private string ProductStructuredData =>
        JsonSerializer.Serialize(new
        {
            context = "https://schema.org",
            type = "Product",
            name = product.Name,
            description = product.Description,
            image = product.ImageUrl,
            offers = new
            {
                type = "Offer",
                price = product.Price,
                priceCurrency = "USD",
                availability = product.InStock
                    ? "https://schema.org/InStock"
                    : "https://schema.org/OutOfStock"
            },
            aggregateRating = new
            {
                type = "AggregateRating",
                ratingValue = product.AverageRating,
                reviewCount = product.ReviewCount
            }
        }, new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase
        });
}

Progressive Enhancement Pattern

Make your site work without JavaScript. Forms submit via HTTP POST. Links navigate with full page loads. Interactive features enhance the experience but aren't required.

Progressive Form
@* Works without JavaScript *@
@* Enhanced version with JavaScript *@ @if (!string.IsNullOrEmpty(message)) {
@message
} @code { [SupplyParameterFromForm] private NewsletterModel model { get; set; } = new(); private bool isSubmitting; private bool success; private string? message; private async Task HandleSubmit() { isSubmitting = true; try { await NewsletterService.SubscribeAsync(model.Email); success = true; message = "Thanks for subscribing!"; model = new(); } catch { success = false; message = "Subscription failed. Please try again."; } finally { isSubmitting = false; } } }

NoScript Fallback

Provide fallback content for users without JavaScript. This helps search bots and accessibility tools.

NoScript Fallback



@if (isInteractive) { } else { View Cart (@itemCount items) }
Crawlability Checklist

Use SSR for all public-facing pages. Add structured data for products, articles, and FAQs. Test with Google Search Console and Lighthouse. Ensure canonical URLs are correct. Provide alt text for all images. Use semantic HTML tags.

Error Handling & Resilience

Network failures happen. SignalR connections drop. Database queries timeout. Your app needs to handle errors gracefully and recover automatically when possible.

Error Boundaries

Error boundaries catch exceptions in child components and display fallback UI. This prevents the entire app from crashing when one component fails.

Error Boundary
@* App.razor or Layout *@

    
        @Body
    
    
        

Oops! Something went wrong

We're sorry for the inconvenience. Please try refreshing the page.

@if (isDevelopment) {
Error Details (Development Only)
@error.Message
@error.StackTrace
}
@code { private bool isDevelopment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development"; private void Recover() { Nav.NavigateTo(Nav.Uri, forceLoad: true); } }

SignalR Reconnection

InteractiveServer components lose their connection if the network drops. Handle reconnection attempts and notify users when the connection is unstable.

Reconnection Handler



Circuit Disposal

Server circuits have limited lifetimes. Save critical state before the circuit disposes. Use IAsyncDisposable to clean up resources.

Circuit Cleanup
@implements IAsyncDisposable
@inject ProtectedSessionStorage Storage

private Timer? autosaveTimer;

protected override void OnInitialized()
{
    // Autosave every 30 seconds
    autosaveTimer = new Timer(async _ =>
    {
        await SaveState();
    }, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
}

private async Task SaveState()
{
    await Storage.SetAsync("form-data", formData);
}

public async ValueTask DisposeAsync()
{
    // Save one last time before disposal
    await SaveState();

    autosaveTimer?.Dispose();
}
Resilience Patterns

Use Polly for retry policies on API calls. Implement circuit breakers for failing services. Cache responses to serve stale data during outages. Log errors to monitoring tools like Application Insights. Test failure scenarios in development.

Production Deployment

Deploying hybrid Blazor apps requires careful configuration. Prerendering, logging, identity integration, and security settings need attention.

Prerendering Configuration

Prerendering generates static HTML at build time for known routes. This eliminates TTFB for those pages. Configure prerendering in Program.cs.

Prerendering Setup
// Program.cs
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

var app = builder.Build();

app.MapRazorComponents()
    .AddInteractiveServerRenderMode()
    .AddInteractiveWebAssemblyRenderMode()
    .AddAdditionalAssemblies(typeof(ProductCatalog.Client._Imports).Assembly);

// Prerender specific routes
app.MapStaticAssets();

app.Run();

Production Logging

Configure structured logging for production. Log errors, performance metrics, and user actions to diagnose issues.

Logging Configuration
// appsettings.Production.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.AspNetCore.SignalR": "Information",
      "Microsoft.AspNetCore.Http.Connections": "Information"
    },
    "ApplicationInsights": {
      "LogLevel": {
        "Default": "Information"
      }
    }
  },
  "ApplicationInsights": {
    "ConnectionString": "InstrumentationKey=your-key-here"
  }
}

// Program.cs
builder.Services.AddApplicationInsightsTelemetry();

builder.Logging.AddApplicationInsights();
builder.Logging.AddConsole();
builder.Logging.AddDebug();

Security Configuration

Enable HTTPS, configure CORS, add Content Security Policy headers, and implement rate limiting for production.

Security Hardening
// Program.cs
var builder = WebApplication.CreateBuilder(args);

// HTTPS redirection
builder.Services.AddHttpsRedirection(options =>
{
    options.HttpsPort = 443;
});

// HSTS for production
builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
    options.Preload = true;
});

// CORS configuration
builder.Services.AddCors(options =>
{
    options.AddPolicy("Production", policy =>
    {
        policy.WithOrigins("https://yoursite.com")
              .AllowAnyMethod()
              .AllowAnyHeader()
              .AllowCredentials();
    });
});

var app = builder.Build();

if (app.Environment.IsProduction())
{
    app.UseHsts();
    app.UseHttpsRedirection();
}

app.UseCors("Production");
app.UseAntiforgery();

app.Run();
Deployment Checklist

Enable response compression. Configure CDN for static assets. Set up health checks. Monitor SignalR connection limits. Configure autoscaling based on CPU and memory. Test failover scenarios. Back up configuration and secrets.

Migration Guide

Migrating from Blazor Server (.NET 7) or Blazor WebAssembly (.NET 7) to the unified model in .NET 8 requires understanding render mode changes.

From Blazor Server (.NET 7)

Blazor Server in .NET 7 was always interactive. In .NET 8, you must explicitly choose render modes. Add @rendermode InteractiveServer to components that need interactivity.

Before (.NET 7)
@* Blazor Server - always interactive *@
@page "/counter"

Counter

Current count: @currentCount

@code { private int currentCount = 0; private void IncrementCount() => currentCount++; }
After (.NET 8)
@* Choose render mode explicitly *@
@page "/counter"
@rendermode InteractiveServer

Counter

Current count: @currentCount

@code { private int currentCount = 0; private void IncrementCount() => currentCount++; }

From WebAssembly (.NET 7)

Blazor WebAssembly in .NET 7 was standalone. In .NET 8, WebAssembly components live in a separate Client project and are hosted by the server project.

Migration Steps
# 1. Create new .NET 8 Blazor Web App
dotnet new blazor -o MyApp -int Auto

# 2. Copy components from .NET 7 WASM project to Client project
cp -r OldApp/Pages/* MyApp.Client/Pages/
cp -r OldApp/Shared/* MyApp.Client/Shared/

# 3. Update Program.cs to register services
# Server/Program.cs
builder.Services.AddRazorComponents()
    .AddInteractiveWebAssemblyComponents();

# 4. Add render mode to components
@rendermode InteractiveWebAssembly

# 5. Update service registrations
# Client project needs HttpClient, Server project needs DbContext

Breaking Changes

NavigationManager API changes. HttpClient configuration is different. Some component lifecycle methods have new signatures.

API Updates
// .NET 7: NavigationManager.NavigateTo
Nav.NavigateTo("/products", forceLoad: true);

// .NET 8: Same API, but behavior differs with render modes
Nav.NavigateTo("/products", forceLoad: true);

// .NET 7: HttpClient registration (WebAssembly)
builder.Services.AddScoped(sp =>
    new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

// .NET 8: Separate registration for Client project
// Client/Program.cs
builder.Services.AddScoped(sp =>
    new HttpClient { BaseAddress = new Uri("https://api.yoursite.com") });
Migration Strategy

Start with SSR for all pages. Identify components that need interactivity. Add render mode attributes incrementally. Test each component individually. Use Auto mode as default for new features. Gradually refactor to leverage streaming and islands.

Frequently Asked Questions

When should I use SSR vs interactive render modes?

Use SSR for content-heavy pages where SEO and fast initial load matter most. Product listings, blog posts, and landing pages work great with SSR. Add interactive islands only where you need client-side behavior like cart updates, filters, or real-time features. This hybrid approach gives you SEO benefits with selective interactivity.

How does auto render mode decide between Server and WebAssembly?

Auto mode starts with SSR for the first render, then checks if the WebAssembly runtime is downloaded. If available, it switches to WebAssembly for offline capability. If not, it uses InteractiveServer with SignalR. The mode persists across navigation, so once WASM loads, all subsequent interactive components use it.

Can I preserve form state during streaming SSR?

Yes, but you need to handle it carefully. Use anti-forgery tokens and persist form values in hidden fields or local storage. When streaming completes, reinitialize form state from persisted data. For interactive forms, consider using islands with InteractiveServer mode instead of SSR to maintain natural state.

What's the performance cost of hydration?

Hydration adds 50-200ms depending on component complexity and JavaScript bundle size. Interactive Server hydrates faster since it only needs SignalR setup. WebAssembly requires downloading the .NET runtime (about 1.5MB gzipped), which takes 200-500ms on good connections. Use islands strategically to minimize hydration cost.

How do search engines handle streaming SSR content?

Modern search bots wait for full page load, so they'll see your complete streamed content. However, initial HTML snapshots may miss late-streaming sections. For critical SEO content like product details or articles, render it in the first chunk. Use streaming for secondary content like recommendations or reviews.

Back to Tutorials