Skip to main content

Building a Real-Time Blazor WASM AI Chat App with Microsoft.Extensions.AI

A practical .NET 10 guide to a guarded, streaming Blazor WebAssembly chat application built on Microsoft.Extensions.AI.

  1. Posts/

Building a Real-Time Blazor WASM AI Chat App with Microsoft.Extensions.AI

·3121 words·15 mins· loading
👤

Chris Malpass

Author

If there’s one truth in modern software development, it’s that bolting AI onto an existing architecture without a strategic layer of abstraction is a fast track to technical debt.

When OpenAI first popularized the streaming chat completion endpoint, the ecosystem scrambled to build HTTP wrappers. Then came Anthropic. Then Gemini. Then local LLMs via Ollama. Suddenly, developers found themselves rewriting their integration layers every three months just to try out a new model. We needed a unified abstraction.

Enter Microsoft.Extensions.AI.

In this guide, we are going to build a production-oriented Blazor WebAssembly AI Chat Application. Rather than wiring the browser directly to a model provider, the client talks to an ASP.NET Core “Agent Gateway”. The gateway owns provider credentials, validates and bounds requests, and uses IChatClient to stream a response back to the browser at a controlled render cadence.

[!TIP] The complete, MIT-licensed source code for the demonstration built in this article is available on GitHub: cmalpass/blazor-wasm-agent-chat.

The zero-configuration experience
#

The companion repository is intentionally useful immediately after cloning: dotnet run --project BlazorAiChat starts the Development profile, which uses the in-memory SimulatedChatClient and never asks for model credentials. That is the exact flow exercised by its Playwright smoke test.

The browser smoke test submits a prompt and receives a streamed simulated response.

The browser test verifies the page renders, the streaming-only Stop control appears, and the complete response reaches the UI. Its successful screenshot is retained as a GitHub Actions artifact, and failed runs add a trace, so the README image is backed by a repeatable interaction rather than a hand-waved mock-up.


Why Blazor WASM for AI?
#

Blazor WebAssembly (WASM) lets us run .NET code in the browser. One useful consequence for an AI application is a shared transport contract between the client and gateway.

In this sample, ChatRequest and ChatMessageDto live in the client project, which the server already references. That gives both sides one small, strongly typed JSON contract while keeping provider-specific Microsoft.Extensions.AI objects on the trusted server. The response is deliberately a raw text/plain stream, so the client reads it with a StreamReader rather than ReadFromJsonAsAsyncEnumerable().

However, running AI logic directly in the client browser presents a severe security risk. You must never embed your LLM API keys (OpenAI, Anthropic, Azure) in a WebAssembly payload. The client environment is fundamentally untrusted; anyone can inspect the browser dev tools, extract your DLLs, and recover hardcoded tokens.

To solve this, we employ the Agent Gateway Pattern:

flowchart LR
    subgraph Browser ["Client (Browser Runtime)"]
        WASM["Blazor WASM\n(Chat.razor)"]
    end

    subgraph Backend ["ASP.NET Core Backend (Trusted Server)"]
        Gateway["Agent Gateway API\n(/api/chat)"]
        MEAI["Microsoft.Extensions.AI\n(IChatClient)"]
    end

    subgraph Providers ["AI Model Providers"]
        Ollama["Local Ollama\n(phi3 / llama3.1)"]
        Azure["Azure OpenAI\n(gpt-4o)"]
        OpenRouter["OpenRouter / Anthropic"]
    end

    WASM -->|"HTTP POST (Stream chunks)"| Gateway
    Gateway --> MEAI
    MEAI --> Ollama
    MEAI --> Azure
    MEAI --> OpenRouter
  1. Frontend (Blazor WASM): Handles the UI, raw-text stream consumption, bounded conversation state, and render coalescing.
  2. Backend (ASP.NET Core Minimal API): Holds provider credentials, validates and bounds the conversation, rate-limits and times out requests, emits safe operational telemetry, and injects the IChatClient implementation. Production requests require authentication.

The Power of IChatClient
#

Before we write the code, let’s talk about IChatClient. Located in the Microsoft.Extensions.AI.Abstractions NuGet package, it is the standard abstraction for chat-based LLM interactions in .NET.

Instead of coding against a concrete vendor SDK (like Azure.AI.OpenAI or Anthropic.SDK), you code against IChatClient:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public interface IChatClient : IDisposable
{
    Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> chatMessages, 
        ChatOptions? options = null, 
        CancellationToken cancellationToken = default);

    IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> chatMessages, 
        ChatOptions? options = null, 
        CancellationToken cancellationToken = default);

    object? GetService(Type serviceType, object? serviceKey = null);
}

This means your application logic never knows whether it’s talking to a massive GPT-4o cluster in Azure, or a small Phi-3 model running locally via Ollama. It simply receives an IAsyncEnumerable of updates and processes them.

[!NOTE] For a broader look at how Microsoft’s agentic tooling is evolving, check out our deep dive on The Microsoft Agent Framework: What .NET Developers Need to Know.


Building the Solution
#

Let’s scaffold our architecture with the .NET 10 Blazor Web App template configured for interactive WebAssembly.

1
2
dotnet new blazor -int WebAssembly -o BlazorAiChat
cd BlazorAiChat

This yields a solution with two key projects:

  • BlazorAiChat (The ASP.NET Core Backend)
  • BlazorAiChat.Client (The Blazor WASM Frontend)

1. The Backend Gateway (ASP.NET Core)
#

First, add the AI abstraction and the JWT handler used outside Development:

1
2
dotnet add BlazorAiChat/BlazorAiChat.csproj package Microsoft.Extensions.AI
dotnet add BlazorAiChat/BlazorAiChat.csproj package Microsoft.AspNetCore.Authentication.JwtBearer

The small shared contract carries only user/assistant content. This protects the browser from a provider SDK and lets the gateway validate a simple, stable shape:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// BlazorAiChat.Client/Models/ChatContracts.cs
namespace BlazorAiChat.Contracts;

public sealed class ChatRequest
{
    public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
}

public sealed class ChatMessageDto
{
    public string Role { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
}

In Program.cs, we register IChatClient. For local development and deterministic tests, the sample uses a simulated client. In a production deployment, register a real provider and keep its credential in server-side configuration or a managed identity.

To keep this tutorial provider-agnostic, let’s create a Minimal API endpoint that consumes whatever IChatClient is registered in the Dependency Injection container:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
// Program.cs (Server)
using BlazorAiChat;
using BlazorAiChat.Client.Pages;
using BlazorAiChat.Components;
using BlazorAiChat.Contracts;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.Timeouts;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.AI;
using System.Diagnostics;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorComponents()
    .AddInteractiveWebAssemblyComponents();

// Required while Chat.razor is prerendered on the server.
builder.Services.AddHttpClient();

// A deterministic provider for local development and tests.
builder.Services.AddSingleton<IChatClient, SimulatedChatClient>();

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer();
builder.Services.AddAuthorization();

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("chat", context =>
    {
        var key = context.User.Identity?.Name
            ?? context.Connection.RemoteIpAddress?.ToString()
            ?? "unknown";

        return RateLimitPartition.GetTokenBucketLimiter(key, _ => new()
        {
            TokenLimit = 10,
            TokensPerPeriod = 10,
            ReplenishmentPeriod = TimeSpan.FromMinutes(1),
            AutoReplenishment = true,
            QueueLimit = 0,
            QueueProcessingOrder = QueueProcessingOrder.OldestFirst
        });
    });
});

builder.Services.AddRequestTimeouts(options =>
    options.AddPolicy("chat", TimeSpan.FromSeconds(90)));

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
}
else
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    app.UseHsts();
}

app.UseWhen(
    context => !context.Request.Path.StartsWithSegments("/api"),
    branch => branch.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true));

app.UseHttpsRedirection();
app.UseAntiforgery();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseRequestTimeouts();
app.MapStaticAssets();

var chatEndpoint = app.MapPost("/api/chat", (IChatClient chatClient, ChatRequest request, HttpContext context, ILogger<Program> logger) =>
{
    const int maximumMessages = 20;
    const int maximumMessageLength = 4_000;
    const int maximumConversationLength = 8_000;

    if (request.Messages.Count is 0 or > maximumMessages)
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["messages"] = ["Provide between one and 20 messages."]
        });

    var totalLength = 0;
    var messages = new List<ChatMessage>(request.Messages.Count);
    foreach (var message in request.Messages)
    {
        var content = message.Content?.Trim();
        if (string.IsNullOrWhiteSpace(content) || content.Length > maximumMessageLength)
            return Results.ValidationProblem(new Dictionary<string, string[]>
            {
                ["messages"] = [$"Each message must contain between one and {maximumMessageLength} characters."]
            });

        totalLength += content.Length;
        if (totalLength > maximumConversationLength)
            return Results.ValidationProblem(new Dictionary<string, string[]>
            {
                ["messages"] = [$"The conversation must not exceed {maximumConversationLength} characters."]
            });

        ChatRole? role = message.Role?.ToLowerInvariant() switch
        {
            "user" => ChatRole.User,
            "assistant" => ChatRole.Assistant,
            _ => null
        };
        if (role is null)
            return Results.ValidationProblem(new Dictionary<string, string[]>
            {
                ["messages"] = ["Only user and assistant messages are accepted."]
            });

        messages.Add(new ChatMessage(role.Value, content));
    }

    return Results.Stream(async (stream) =>
    {
        using var activity = ChatTelemetry.ActivitySource.StartActivity("chat.completion");
        activity?.SetTag("ai.chat.message_count", messages.Count);
        ChatTelemetry.Requests.Add(1);

        var chunksWritten = 0;
        try
        {
            await foreach (var chunk in chatClient.GetStreamingResponseAsync(messages, cancellationToken: context.RequestAborted)
                .WithCancellation(context.RequestAborted))
            {
                if (chunk.Text is { Length: > 0 } text)
                {
                    await stream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(text), context.RequestAborted);
                    await stream.FlushAsync(context.RequestAborted);
                    chunksWritten++;
                }
            }

            ChatTelemetry.Responses.Add(1, new("outcome", "success"));
            logger.LogInformation("Streamed {ChunkCount} chat response chunks.", chunksWritten);
        }
        catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
        {
            ChatTelemetry.Responses.Add(1, new("outcome", "cancelled"));
            logger.LogInformation("Chat response was cancelled after {ChunkCount} chunks.", chunksWritten);
        }
        catch (Exception exception)
        {
            ChatTelemetry.Responses.Add(1, new("outcome", "failed"));
            activity?.SetStatus(ActivityStatusCode.Error);
            logger.LogError(exception, "Chat provider failed after {ChunkCount} chunks.", chunksWritten);
        }
        finally
        {
            ChatTelemetry.ResponseChunks.Add(chunksWritten);
        }
    }, "text/plain; charset=utf-8");
});

chatEndpoint.RequireRateLimiting("chat").WithRequestTimeout("chat");
if (!app.Environment.IsDevelopment())
    chatEndpoint.RequireAuthorization();

app.MapRazorComponents<App>()
    .AddInteractiveWebAssemblyRenderMode()
    .AddAdditionalAssemblies(typeof(BlazorAiChat.Client._Imports).Assembly);

app.Run();

public partial class Program { }

The gateway intentionally permits anonymous requests only in the Development environment, where it uses the simulated provider. Every other environment rejects anonymous requests with 401; configure a validated bearer-token issuer through the standard Authentication:Schemes:Bearer configuration section. Minimal API authentication guidance and rate-limiting guidance cover the production integration details.

The ChatTelemetry helper exposes an ActivitySource and Meter without logging prompts or completions. Connect those standard .NET hooks to an OpenTelemetry exporter in the host that owns your observability configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
using System.Diagnostics;
using System.Diagnostics.Metrics;

internal static class ChatTelemetry
{
    public static readonly ActivitySource ActivitySource = new("BlazorAiChat.Chat");
    private static readonly Meter Meter = new("BlazorAiChat.Chat");

    public static readonly Counter<long> Requests = Meter.CreateCounter<long>("chat.requests");
    public static readonly Counter<long> Responses = Meter.CreateCounter<long>("chat.responses");
    public static readonly Counter<long> ResponseChunks = Meter.CreateCounter<long>("chat.response.chunks");
}

2. The Frontend (Blazor WebAssembly)
#

Now, let’s move to the BlazorAiChat.Client project.

First, ensure the WASM client has an HttpClient configured with the correct base address in its Program.cs. The runnable sample is anonymous only in Development; in production, integrate an identity provider or BFF that supplies a short-lived bearer token. Do not hard-code that token—or any model-provider key—into the WebAssembly application:

1
2
3
4
5
6
7
8
9
// Program.cs (Client)
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

var builder = WebAssemblyHostBuilder.CreateDefault(args);

// Register HttpClient to communicate with our ASP.NET Core backend
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

await builder.Build().RunAsync();

Next, let’s build the interactive Chat interface in Chat.razor.

Handling streamed responses well requires two safeguards. In Blazor WebAssembly, HttpCompletionOption.ResponseHeadersRead must be paired with SetBrowserResponseStreamingEnabled(true); otherwise the browser implementation may buffer the response. Microsoft’s Blazor guidance documents both requirements.

The second safeguard is render coalescing. A delay after every chunk is not a debounce: it can still render once per chunk. The component accumulates text immediately but publishes it to the DOM no more than once every 50 ms. It also keeps the cancellation token source so the user can stop a long response:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
@page "/chat"
@inject HttpClient Http
@using System.Text
@using System.Diagnostics
@using BlazorAiChat.Contracts
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@implements IDisposable
@rendermode InteractiveWebAssembly

<PageTitle>AI Chat</PageTitle>

<h1>AI Agent Chat</h1>

<div class="chat-container">
    <div class="messages mb-3" style="min-height: 200px; max-height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 1rem;">
        @foreach (var msg in Messages)
        {
            <div class="message @(msg.Role.ToLower()) mb-3">
                <strong>@msg.Role:</strong> 
                <span>@msg.Content</span>
            </div>
        }
    </div>

    <div class="input-group">
        <input @bind="Prompt" @bind:event="oninput" @onkeyup="HandleKeyUp" class="form-control" placeholder="Type a message..." disabled="@IsLoading" />
        <button class="btn btn-primary" @onclick="SendMessage" disabled="@(IsLoading || string.IsNullOrWhiteSpace(Prompt))">
            @if (IsLoading)
            {
                <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
            }
            else
            {
                <span>Send</span>
            }
        </button>
        @if (IsLoading)
        {
            <button class="btn btn-outline-secondary" @onclick="CancelResponse">Stop</button>
        }
    </div>
</div>

@code {
    private string Prompt { get; set; } = "";
    private bool IsLoading { get; set; } = false;
    private List<ChatMessageDto> Messages { get; set; } = new();
    private CancellationTokenSource? activeRequestCancellation;

    private async Task HandleKeyUp(KeyboardEventArgs e)
    {
        if (e.Key == "Enter" && !IsLoading && !string.IsNullOrWhiteSpace(Prompt))
        {
            await SendMessage();
        }
    }

    private async Task SendMessage()
    {
        if (string.IsNullOrWhiteSpace(Prompt)) return;

        var cancellationSource = new CancellationTokenSource();
        var userPrompt = Prompt;
        Prompt = "";
        IsLoading = true;

        Messages.Add(new ChatMessageDto { Role = "User", Content = userPrompt });
        var requestBody = new ChatRequest
        {
            Messages = Messages.Select(message => new ChatMessageDto
            {
                Role = message.Role,
                Content = message.Content
            }).ToList()
        };

        var assistantMessage = new ChatMessageDto { Role = "Assistant", Content = "" };
        Messages.Add(assistantMessage);

        StateHasChanged();

        try
        {
            using var request = new HttpRequestMessage(HttpMethod.Post, "/api/chat")
            {
                Content = new StringContent(
                    System.Text.Json.JsonSerializer.Serialize(requestBody),
                    Encoding.UTF8,
                    "application/json")
            };

            request.SetBrowserResponseStreamingEnabled(true);
            activeRequestCancellation = cancellationSource;
            using var response = await Http.SendAsync(
                request, HttpCompletionOption.ResponseHeadersRead, cancellationSource.Token);
            response.EnsureSuccessStatusCode();

            using var stream = await response.Content.ReadAsStreamAsync(cancellationSource.Token);
            using var reader = new StreamReader(stream);

            char[] buffer = new char[32];
            int bytesRead;
            var accumulatedResponse = new StringBuilder();
            var lastRender = Stopwatch.GetTimestamp();
            while ((bytesRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0)
            {
                accumulatedResponse.Append(buffer, 0, bytesRead);
                if (Stopwatch.GetElapsedTime(lastRender) >= TimeSpan.FromMilliseconds(50))
                {
                    assistantMessage.Content = accumulatedResponse.ToString();
                    await InvokeAsync(StateHasChanged);
                    lastRender = Stopwatch.GetTimestamp();
                }
            }

            assistantMessage.Content = accumulatedResponse.ToString();
        }
        catch (OperationCanceledException)
        {
            assistantMessage.Content += "[Response cancelled.]";
        }
        catch (HttpRequestException)
        {
            assistantMessage.Content += "[The chat service is unavailable. Please try again.]";
        }
        finally
        {
            if (ReferenceEquals(activeRequestCancellation, cancellationSource))
            {
                activeRequestCancellation = null;
            }

            cancellationSource.Dispose();
            IsLoading = false;
            StateHasChanged();
        }
    }

    private void CancelResponse() => activeRequestCancellation?.Cancel();

    public void Dispose() => activeRequestCancellation?.Cancel();
}

Testing the Gateway and Component
#

A critical aspect of building AI-backed applications in .NET is ensuring your integration pipeline has deterministic tests. Because LLMs are non-deterministic and external APIs can fail or charge per token, you should test the streaming pipeline with simulated providers and automated component test harnesses:

  1. Integration Tests (WebApplicationFactory<Program>): Verify a valid conversation returns text/plain, invalid roles are rejected as validation problems, and the production environment refuses anonymous requests.
  2. Component Tests (bUnit): Render Chat.razor headlessly, simulate user input, mock the HTTP response, and verify that streamed text and safe error states render correctly.
  3. Browser Smoke Test (Playwright): Start the Development profile, submit a prompt, observe the visible streaming state, verify the completed response, and attach a screenshot to the test result.

The Playwright check proves the zero-configuration browser happy path, while the in-process tests cover gateway edges much faster. It is not a substitute for provider-specific or identity-provider scenarios; add those as you introduce them.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
[Fact]
public async Task ChatPage_SendingMessage_StreamsContentIntoMessageList()
{
    // Arrange: a deterministic response for the component
    var mockHandler = new MockHttpMessageHandler(req => new HttpResponseMessage(HttpStatusCode.OK)
    {
        Content = new StringContent("Hello! I am an AI response.", Encoding.UTF8, "text/plain")
    });

    var httpClient = new HttpClient(mockHandler) { BaseAddress = new Uri("http://localhost") };
    Services.AddSingleton(httpClient);

    var cut = Render<Chat>();

    // Act: Enter text and click send
    cut.Find("input.form-control").Input("Tell me a joke");
    await cut.InvokeAsync(() => cut.Find("button.btn.btn-primary").Click());

    // Assert: Verify state update
    cut.WaitForState(() => cut.FindAll(".message").Count >= 2);
    cut.FindAll(".message")[1].TextContent.Should().Contain("Hello! I am an AI response.");
}

The companion repository runs eight deterministic .NET tests with dotnet test BlazorAiChat.sln --configuration Release, plus npm run test:e2e for the browser flow. “All tests passing” is not a coverage claim; keep expanding both suites as you add providers and deployment concerns.


Performance & Rendering Considerations
#

The sample intentionally renders plain text and keeps a bounded in-memory conversation. Real-world applications introduce additional complexities: Markdown rendering, persistence, and much larger histories.

1. Markdown Parsing in WASM
#

Most LLMs output Markdown. If you try to run a regex or naive string replacement on the streaming text, you will experience severe performance degradation. Instead, use a highly optimized abstract syntax tree (AST) parser like Markdig. Because it’s a pure .NET library, it compiles directly to WASM and runs entirely in the browser.

For the highest performance, do not re-parse the entire Markdown string on every token. Maintain a raw string buffer, render on the same coalesced cadence as the UI, and sanitize the resulting HTML before putting it in the DOM.

2. UI Virtualization
#

If a conversation reaches 50,000 tokens, rendering every message in the DOM will consume significant client-side memory and cause scroll lagging. Blazor provides a built-in <Virtualize> component. By wrapping your Messages loop in <Virtualize>, Blazor will only render the DOM nodes currently visible in the viewport, destroying and recycling nodes as the user scrolls.


Connecting Local LLMs (Ollama)
#

The biggest win of this architecture is how easily we can transition from cloud to local development. If you don’t want to burn OpenAI credits while developing the UI, you can route the backend to a local Ollama instance running llama3.1 or qwen2.5-coder.

Because the browser depends only on the gateway contract, the provider change is contained to the backend. You still need to add the provider package, configure its endpoint/model, and apply provider-specific resiliency and observability options.

Using the community standard OllamaSharp package (which natively implements MEAI abstractions):

1
2
3
4
5
6
// Program.cs
using OllamaSharp;

// Keep this on the server: it talks to the local Ollama process.
builder.Services.AddSingleton<IChatClient>(
    new OllamaApiClient(new Uri("http://localhost:11434"), "phi3"));

The frontend Blazor WASM client remains completely unchanged. It doesn’t know it’s suddenly talking to a local model instead of the cloud; it just knows it’s receiving a text stream.

[!TIP] To learn how to set up and self-host Ollama on your local workstation, read our guide on Running Local AI Models with .NET and Ollama. When you’re ready for containerized deployment, see Deploying .NET AI Applications with Docker.


Summary
#

By leveraging Microsoft.Extensions.AI on the backend and Blazor WASM on the frontend, we built a provider-agnostic chat foundation. Provider credentials stay behind the gateway; the client and server share a deliberately small transport DTO; browser response streaming is explicitly enabled; and rendering is coalesced to handle high-speed chunks without claiming the UI can never jank.

Before deploying a real provider, configure JWT validation, tune the rate and timeout policies for your model and tenant plan, attach an OpenTelemetry exporter, and add browser-level tests for the flows you support. Those details are not optional operational polish: they are what turns a useful sample into a safe service.

The AI landscape changes daily, but solid architectural abstractions ensure your application code stands the test of time.

[!IMPORTANT] To see the entire implementation working end-to-end, pull down the companion repository: cmalpass/blazor-wasm-agent-chat.

Happy coding, and let me know on Twitter/X if you build something awesome with this!