Blazor Overview: Web Development with .NET

Blazor Overview: Complete .NET Web Dev Guide

Master Blazor fast: models, components, data binding, forms, routing, DI, JS interop, SSR/WASM choices, performance, and deployment.

.NET Development·By amarozka · September 12, 2025

Blazor Overview: Complete .NET Web Dev Guide

Are you still juggling React on the front end and ASP.NET on the back end just to ship a simple CRUD? What if you could build the entire web stack in C#, share models end‑to‑end, and keep your brain in one language all day? That’s exactly why Blazor exists – and why it’s now my default choice for many .NET web apps.

Blazor in one paragraph

Blazor is a component-based web UI framework in .NET. You write Razor components (.razor files) in C#/HTML, and Blazor renders them either on the server (over SignalR), in the browser via WebAssembly (WASM), in native shells via .NET MAUI (Hybrid), or with Server-Side Rendering (SSR) plus progressive interactivity. Same components, multiple render modes.

Hosting & render models (when to use which)

Blazor has four practical ways to run UI code. Think of them as a spectrum between instant load and full offline/edge execution.

1) Blazor Server

  • How it works: Components run on the server; UI diffs (events & DOM patches) travel via SignalR.
  • Pros: Fast first render; tiny payloads; .NET libraries “just work”; server secrets stay server-side.
  • Cons: Requires persistent connection; latency affects UX; scale depends on server resources/circuits.
  • Use when: Intranet/line-of-business apps, dashboards, forms-heavy portals, authenticated systems.

2) Blazor WebAssembly (WASM)

  • How it works: Components and .NET runtime download to the browser; run client-side.
  • Pros: Offline capable; minimal server load; edge/CDN friendly; no server roundtrips for events.
  • Cons: Initial download; limited browser sandbox; need to call APIs for server data.
  • Use when: Public sites, offline-friendly apps, multi-region static hosting, scaling to zero.

3) Blazor SSR + Interactive (Blazor Web App)

  • How it works: Pages render on the server (great SEO & TTFB). Interactivity can hydrate as Server or WASM per component or page. This is the “best of both” template.
  • Pros: SEO + perf out of the box; progressive enhancement; choose per-route render mode.
  • Cons: Slightly higher complexity; think about where state lives.
  • Use when: You want SSR speed/SEO and rich interactivity on selected parts.

4) Blazor Hybrid (MAUI)

  • How it works: Razor components render in a native WebView, calling local .NET APIs.
  • Pros: Access to native APIs; reuse UI + logic across desktop/mobile.
  • Cons: App store distribution; larger app size.
  • Use when: You want a desktop/mobile app without rewriting your UI.

Quick decision matrix

Quick Blazor decision matrix

Rule of thumb: Start with Blazor Web App (SSR). Hydrate interactivity as Server for internal tools, as WASM for public/edge routes, and keep purely static pages as SSR-only.

Project structure & bootstrapping

A typical Blazor Web App solution (SSR with optional interactive modes) might look like this:

src/
  MyApp/                # ASP.NET Core host (also renders SSR)
    Components/
      App.razor         # Root component
      Layouts/
      Pages/
      Shared/
    wwwroot/
    Program.cs
  MyApp.Client/         # (Optional) WASM-specific assets/components
  MyApp.Core/           # Shared models, services, DTOs

Minimal Program.cs (Blazor Web App)

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()    // opt-in to Server interactivity
    .AddInteractiveWebAssemblyComponents(); // opt-in to WASM interactivity

builder.Services.AddHttpClient();
builder.Services.AddScoped<WeatherService>();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
}

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

app.Run();

Minimal Program.cs (WASM Standalone)

using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");

builder.Services.AddScoped(sp => new HttpClient
{
    BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});

builder.Services.AddScoped<WeatherService>();

await builder.Build().RunAsync();

Components (props, events, slots)

A first component with parameters & events

@* Components/Counter.razor *@
<div class="p-3 border rounded">
  <h3>Counter (@Title)</h3>
  <p>Current: @count</p>
  <button class="btn btn-primary" @onclick="Increment">+1</button>
</div>

@code {
    [Parameter] public string Title { get; set; } = "Demo";
    [Parameter] public EventCallback<int> Changed { get; set; }

    private int count;

    private async Task Increment()
    {
        count++;
        await Changed.InvokeAsync(count);
    }
}

Using RenderFragment (slots) and child content

@* Components/Card.razor *@
<div class="card m-2">
  <div class="card-header">@Header</div>
  <div class="card-body">
    @ChildContent
  </div>
</div>

@code {
    [Parameter] public RenderFragment? Header { get; set; }
    [Parameter] public RenderFragment? ChildContent { get; set; }
}
<Card>
  <Header><strong>Weather</strong></Header>
  <p>It’s sunny today.</p>
</Card>

Cascading values

@* App.razor *@
<CascadingValue Value="_theme">
    <Router AppAssembly="typeof(App).Assembly" />
</CascadingValue>

@code {
    private Theme _theme = new("light");
}
public record Theme(string Name);
@* Components/Title.razor *@
<h2 class="mb-3">@Text (@Theme?.Name)</h2>

@code {
    [Parameter] public string Text { get; set; } = string.Empty;
    [CascadingParameter] public Theme? Theme { get; set; }
}

Lifecycle in practice

Common hooks:

  • OnInitialized{Async} – fetch initial data.
  • OnParametersSet{Async} – react to parameter changes.
  • OnAfterRender{Async} – DOM is available; good for JS interop.
  • ShouldRender – skip re-render if nothing changed.

Routing, layouts, and navigation

Routing

@page "/orders/{id:int}"
@inject NavigationManager Nav

<h2>Order @Id</h2>
<button @onclick="() => Nav.NavigateTo($"/orders/{Id}/edit")">Edit</button>

@code {
    [Parameter] public int Id { get; set; }
}

Layouts

@* Shared/MainLayout.razor *@
@inherits LayoutComponentBase

<div class="container">
  <NavMenu />
  <main class="mt-3">
    @Body
  </main>
</div>

Apply layout per page:

@layout MainLayout

Data binding & forms (the “feel-good” part)

One-way, two-way, and formatting

<input @bind="search" @bind:event="oninput" placeholder="Search..." />
<p>You typed: @search</p>

<input type="date" @bind="birthday" @bind:format="yyyy-MM-dd" />

@code {
    private string? search;
    private DateTime birthday = DateTime.Today;
}

Forms & validation with EditForm

@using System.ComponentModel.DataAnnotations

<EditForm Model="@person" OnValidSubmit="Save">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <div class="mb-2">
        <label>Name</label>
        <InputText class="form-control" @bind-Value="person.Name" />
        <ValidationMessage For="() => person.Name" />
    </div>

    <div class="mb-2">
        <label>Age</label>
        <InputNumber class="form-control" @bind-Value="person.Age" />
        <ValidationMessage For="() => person.Age" />
    </div>

    <button class="btn btn-success" type="submit">Save</button>
</EditForm>

@code {
    private Person person = new();

    private Task Save()
    {
        // Normally call an API here
        Console.WriteLine($"Saved {person.Name} ({person.Age})");
        return Task.CompletedTask;
    }

    public class Person
    {
        [Required, StringLength(50)]
        public string Name { get; set; } = string.Empty;

        [Range(0, 130)]
        public int Age { get; set; }
    }
}

Tip: Prefer input components (InputText, InputNumber, etc.) over bare <input> when using EditForm so validation and value parsing “just work”.

Dependency Injection, HTTP, and configuration

Register services

// Program.cs
builder.Services.AddHttpClient();
builder.Services.AddScoped<IWeatherApi, WeatherApi>();

Inject into components

@inject IWeatherApi Api

<ul>
  @foreach (var f in forecasts)
  {
    <li>@f.Date.ToShortDateString(): @f.Summary (@f.TemperatureC °C)</li>
  }
</ul>

@code {
  private WeatherForecast[] forecasts = [];

  protected override async Task OnInitializedAsync()
  {
      forecasts = await Api.GetAsync();
  }
}

Service implementation (shared in Core)

public interface IWeatherApi
{
    Task<WeatherForecast[]> GetAsync();
}

public sealed class WeatherApi(HttpClient http) : IWeatherApi
{
    public async Task<WeatherForecast[]> GetAsync()
        => await http.GetFromJsonAsync<WeatherForecast[]>("api/weather")
           ?? Array.Empty<WeatherForecast>();
}

public record WeatherForecast(DateTime Date, int TemperatureC, string Summary);

Server vs WASM: In Server, HttpClient calls are local to the server; in WASM they call remote endpoints – plan CORS/auth accordingly.

State management (what lives where)

  • Per-component: fields/@code – resets on navigation.
  • Cascading state: use CascadingValue or a state container service.
  • Persist locally (WASM/SSR): localStorage/sessionStorage via JS interop or ProtectedLocalStorage (for encrypted cookies in Server).
  • Server circuits (Server/SSR interactive): user state is in memory per connection; plan for reconnects.

Simple state container

public class CartState
{
    private readonly List<string> _items = new();
    public IReadOnlyList<string> Items => _items;
    public event Action? Changed;

    public void Add(string sku)
    {
        _items.Add(sku);
        Changed?.Invoke();
    }
}
// Program.cs
builder.Services.AddScoped<CartState>();
@inject CartState Cart

<button @onclick="() => Cart.Add("SKU-123")">Add to cart</button>
<p>Items: @Cart.Items.Count</p>

@code {
    protected override void OnInitialized()
        => Cart.Changed += StateHasChanged;

    public void Dispose() => Cart.Changed -= StateHasChanged;
}

JavaScript interop (only when it helps)

Import an ES module and call a function

@inject IJSRuntime JS

<button @onclick="FocusSearch">Focus search</button>

@code {
    private IJSObjectReference? _module;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _module = await JS.InvokeAsync<IJSObjectReference>(
                "import", "./js/utils.js");
        }
    }

    private async Task FocusSearch()
        => await _module!.InvokeVoidAsync("focusElementById", "search");
}
// wwwroot/js/utils.js
export function focusElementById(id) {
  document.getElementById(id)?.focus();
}

Call .NET from JS

public class VersionInfo
{
    [JSInvokable]
    public static string GetVersion() => "1.0.0";
}
import("./_framework/blazor.web.js").then(() => {
  DotNet.invokeMethodAsync("MyApp", "GetVersion")
        .then(v => console.log("Version:", v));
});

Interop rule: Keep it thin and explicit. If you need heavy client JS, consider an island approach (embed a JS widget inside a Blazor component boundary).

Performance playbook

  • SSR first render: Use the Blazor Web App template for fast TTFB.
  • Streamed rendering: Render above-the-fold content first, then hydrate.
  • Virtualization: Use <Virtualize> for large lists.
  • Key your lists: @key prevents DOM churn on reorders.
  • Throttle inputs: Use @bind:event="oninput" and debounce in JS if needed.
  • AOT for WASM: Enable ahead-of-time compilation for CPU-heavy apps to cut runtime overhead.
  • Trimming & lazy loading: Ship only used assemblies; lazy load feature modules.
  • Images & static assets: CDN + cache headers; prefer modern formats (webp/avif).

AOT publish (WASM)

dotnet publish -c Release -p:RunAOTCompilation=true

Virtualize example

<Virtualize Items="people" ItemSize="36" Context="p">
  <div>@p.Name (@p.Age)</div>
</Virtualize>

@code {
    private List<Person> people = Enumerable.Range(1, 10_000)
        .Select(i => new Person { Name = $"User {i}", Age = 20 + (i % 40) })
        .ToList();
}

Authentication & security

  • Server/SSR: Use ASP.NET Core auth; cookies flow naturally; server-guard sensitive ops.
  • WASM: Use OAuth/OIDC (PKCE) against an identity provider; store tokens safely (avoid localStorage when possible; prefer AuthorizationMessageHandler and IHttpClientFactory).
  • Authorization: Policies & [Authorize] at component or route level.
  • Input validation: Never trust client-only validation; revalidate on the server.
  • Secrets: Keep keys server-side; call the server from WASM to proxy sensitive operations.

Testing your components

  • Unit tests: Use bUnit to render components, assert markup, and inject services.
  • Integration/E2E: Use Playwright to click through pages and assert UI behavior.
  • Services: Mock HttpClient with HttpMessageHandler stubs.

Example bUnit snippet:

using Bunit;
using Xunit;

public class CounterTests : TestContext
{
    [Fact]
    public void Increment_UpdatesCount()
    {
        var cut = RenderComponent<Counter>(p => p.Add(c => c.Title, "Test"));
        cut.Find("button").Click();
        cut.Markup.Contains("Current: 1");
    }
}

Deployment

  • Blazor Server/SSR: Deploy like any ASP.NET Core app (Kestrel behind Nginx/IIS/Load Balancer). Scale out requires sticky sessions or Redis-backed circuits when needed.
  • WASM: Static hosting (Azure Static Web Apps, GitHub Pages, S3+CloudFront, Cloudflare Pages). Point API calls to your backend; enable CORS.
  • CI/CD: dotnet publish -> containerize -> push to registry -> deploy. Cache ~/.nuget/packages to speed builds.

A tiny end-to-end example

Let’s build a Todo with shared model, server API, and WASM/SSR UI.

Shared model

public record TodoItem(Guid Id, string Title, bool Done);

Server endpoint

app.MapGroup("/api/todos").MapTodos();

static class TodoEndpoints
{
    public static RouteGroupBuilder MapTodos(this RouteGroupBuilder group)
    {
        var items = new List<TodoItem>();

        group.MapGet("/", () => items);
        group.MapPost("/", (TodoItem item) => { items.Add(item); return Results.Created($"/api/todos/{item.Id}", item); });
        group.MapPost("/{id:guid}/toggle", (Guid id) =>
        {
            var i = items.FirstOrDefault(x => x.Id == id);
            if (i is null) return Results.NotFound();
            var updated = i with { Done = !i.Done };
            items[items.IndexOf(i)] = updated;
            return Results.Ok(updated);
        });

        return group;
    }
}

UI component

@inject HttpClient Http

<h3>Todos</h3>

<InputText @bind-Value="title" placeholder="What needs to be done?" />
<button class="btn btn-primary" @onclick="Add">Add</button>

<ul>
  @foreach (var t in items)
  {
    <li>
      <input type="checkbox" checked="@t.Done" @onchange="() => Toggle(t)" />
      @t.Title
    </li>
  }
</ul>

@code {
    private string title = string.Empty;
    private List<TodoItem> items = new();

    protected override async Task OnInitializedAsync()
        => items = await Http.GetFromJsonAsync<List<TodoItem>>("/api/todos") ?? new();

    private async Task Add()
    {
        if (string.IsNullOrWhiteSpace(title)) return;
        var item = new TodoItem(Guid.NewGuid(), title.Trim(), false);
        var resp = await Http.PostAsJsonAsync("/api/todos", item);
        if (resp.IsSuccessStatusCode)
        {
            items.Add(item);
            title = string.Empty;
        }
    }

    private async Task Toggle(TodoItem item)
    {
        var resp = await Http.PostAsync($"/api/todos/{item.Id}/toggle", null);
        if (resp.IsSuccessStatusCode)
        {
            var updated = await resp.Content.ReadFromJsonAsync<TodoItem>();
            var idx = items.FindIndex(x => x.Id == item.Id);
            if (idx >= 0 && updated is not null) items[idx] = updated;
        }
    }
}

Common pitfalls (and how I avoid them now)

  • Over-hydrating everything: Don’t make every page interactive. Keep static pages SSR-only.
  • Putting secrets in WASM: Never. Proxy via your server.
  • Ignoring reconnects (Server): Add <ReconnectDisplay /> and handle transient state gracefully.
  • Huge payloads (WASM): Trim assemblies, lazy load, use AOT only when it pays off.
  • Chatty renders: Use @key, memoize computed values, and avoid expensive work in BuildRenderTree.

FAQ: Blazor basics & decisions

Is Blazor ready for production?

Yes. I’ve shipped multiple apps. Choose the right hosting model and mind perf.

Can I mix Blazor with existing MVC/Razor Pages?

Absolutely – host components inside Razor Pages/Views or link to Blazor routes.

How does SEO work?

Use SSR for crawlable HTML. Hydrate interactivity where needed.

Do I need JavaScript at all?

Often less, not zero. Use JS interop for niche browser APIs or specialized widgets.

How do I share models between client and server?

Put DTOs in a shared project/package referenced by both.

Can I go offline?

Use WASM + IndexedDB + service workers. Cache APIs via background sync where appropriate.

How do I debug?

Server: normal .NET debugging. WASM: browser devtools + .NET debugger over WebAssembly.

How do I handle real-time?

Server: SignalR is already in the loop. WASM: call SignalR from the client.

What about component libraries?

Bootstrap-based libs work well. Pick one and stay consistent.

Conclusion: Build full-stack web apps in pure C#

Blazor lets you write one set of components and choose where they run – server, browser, or both. With SSR for speed, Server for productivity, and WASM for reach, you can fit nearly any web scenario while staying in C#. Start with a Blazor Web App, hydrate where it matters, and keep your interop thin.

Your move: clone a template, wire a simple API, and ship your first feature this week. Which hosting model will you pick and why? Share it in the comments – I read them all.

Leave a Reply

Your email address will not be published. Required fields are marked *