A chatbot is the “hello world” of AI apps — and Blazor is a natural fit because you can build the whole thing, UI and model calls, in C#. This guide builds a working AI chat component in Blazor: a message list, an input box, streamed responses that appear token-by-token, and conversation state held across turns. No JavaScript required.
It assumes you can already make a model call — if not, start with how to call the OpenAI API from C#.
The shape of the app
A chat UI needs three things: a list of messages to render, a way to send a new message, and a way to stream the reply in as it generates. We’ll use Microsoft.Extensions.AI’s IChatClient so the same component works with OpenAI, Azure OpenAI, or a local model via Ollama.
Register the chat client in Program.cs:
builder.Services.AddSingleton<IChatClient>(sp =>
new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY"))
.GetChatClient("gpt-4o-mini")
.AsIChatClient());
IChatClient implementations are designed to be shared and thread-safe, so a singleton is right — it reuses the underlying HttpClient and connection pool. What must not be shared is the conversation. More on that below, because getting it wrong is how one user reads another user’s chat.
Two things that stop this working before you see a token
Both are worth knowing up front, because both produce symptoms that look like the model call failing when the model was never called at all.
Render mode. Since .NET 8, components are statically server-rendered unless you say otherwise. A statically rendered component renders its markup perfectly and then does nothing: the button produces no click, @bind does not bind, and there is no error anywhere. You need @rendermode InteractiveServer (or InteractiveAuto) on the component or its parent, and the interactive server services registered in Program.cs. If your chat UI appears and simply ignores you, this is why.
Where the API key lives. The registration above is fine in a Blazor Server app, where it runs on the server. Put the same code in a WebAssembly component and the key is compiled into an assembly that is downloaded by every visitor — your key, publicly readable, in the browser’s network tab. There is no client-side way to hide it. Blazor WASM chat apps must call your own server endpoint, which then calls the model. That is a genuine architectural constraint, not a nuisance to work around, and it is the main reason chat UIs of this shape tend to be Blazor Server or InteractiveAuto with the call kept server-side.
The chat component
Here’s the core of a Chat.razor component — a message list, an input, and a send handler that streams the response as tokens arrive:
@page "/chat"
@rendermode InteractiveServer
@inject IChatClient ChatClient
@inject ILogger<Chat> Logger
@using Microsoft.Extensions.AI
@using System.Text
<div class="messages">
@foreach (var bubble in _display)
{
<div class="msg @bubble.Role">@bubble.Text</div>
}
</div>
<input @bind="_input" @bind:event="oninput" @onkeyup="OnKey"
disabled="@_busy" placeholder="Ask something..." />
<button @onclick="Send" disabled="@_busy">Send</button>
@if (_busy)
{
<button @onclick="Stop">Stop</button>
}
@code {
// What the model sees.
private readonly List<ChatMessage> _history =
[
new(ChatRole.System, "You are a concise support assistant. If you don't know, say so.")
];
// What the user sees. ChatMessage.Text is read-only, so the reply that
// grows token by token lives in a small mutable view model of our own.
private sealed class Bubble
{
public required string Role { get; init; }
public string Text { get; set; } = "";
}
private readonly List<Bubble> _display = [];
private string _input = "";
private bool _busy;
private CancellationTokenSource? _cts;
private async Task Send()
{
if (string.IsNullOrWhiteSpace(_input) || _busy) return;
_busy = true;
_cts = new CancellationTokenSource();
var question = _input;
_input = "";
_history.Add(new ChatMessage(ChatRole.User, question));
_display.Add(new Bubble { Role = "user", Text = question });
var bubble = new Bubble { Role = "assistant" };
_display.Add(bubble);
var reply = new StringBuilder();
try
{
await foreach (var update in ChatClient.GetStreamingResponseAsync(
_history, cancellationToken: _cts.Token))
{
reply.Append(update.Text);
bubble.Text = reply.ToString();
StateHasChanged(); // re-render as each chunk arrives
}
_history.Add(new ChatMessage(ChatRole.Assistant, reply.ToString()));
}
catch (OperationCanceledException)
{
bubble.Text = reply.Length > 0 ? reply + " (stopped)" : "(stopped)";
}
catch (Exception ex)
{
Logger.LogError(ex, "Chat request failed");
bubble.Text = "Something went wrong. Please try again.";
}
finally
{
_busy = false;
_cts?.Dispose();
_cts = null;
}
}
private void Stop() => _cts?.Cancel();
private async Task OnKey(KeyboardEventArgs e)
{
if (e.Key == "Enter") await Send();
}
}
The key move is StateHasChanged() inside the streaming loop — that re-renders the component on each chunk, so users watch the answer type itself out. Passing the whole _history list each turn is what gives the bot memory of the conversation.
Why the rendered list isn’t the message history
The tempting shortcut is to bind the UI straight to List<ChatMessage> and append tokens to the last message. It doesn’t work: ChatMessage.Text is a read-only computed property that concatenates the TextContent items in Contents, so there is nothing to append to. The compiler catches that particular mistake, which is the good outcome.
The better reason to keep two lists is that they genuinely are two different things. The history is the model’s input and must contain exactly what you want billed and interpreted — system prompt, user turns, completed assistant turns. The display list is presentation, and it holds things the model should never see: a half-streamed reply, a “(stopped)” marker, an error bubble, a spinner row, a retry button. Merge them and you end up sending your own error text back to the model as if it had said it, which produces some memorably strange conversations.
Note what the code does on cancellation and on failure: it leaves the partial or failed turn out of _history. The user’s question stays in, so a retry re-sends it cleanly. Decide this deliberately either way — an assistant turn silently missing from history is confusing, and a truncated one is worse, so pick one and make the UI say which happened.
One render per token is fine, until it isn’t
On Blazor Server, every StateHasChanged() produces a render, a diff against the previous tree, and a binary patch pushed down the SignalR circuit. At maybe thirty tokens a second for one user that is nothing. At a hundred concurrent conversations it is a few thousand render-and-diff cycles a second on the server, each one re-rendering the entire message list because the last bubble’s text changed.
Two cheap mitigations. Put @key on the message elements so the diff engine can match existing rows instead of reconsidering the list, and throttle the re-render rather than the append:
long lastRender = 0;
await foreach (var update in ChatClient.GetStreamingResponseAsync(
_history, cancellationToken: _cts.Token))
{
reply.Append(update.Text);
// Repaint at most ~20 times a second; the text still arrives in full.
if (Environment.TickCount64 - lastRender >= 50)
{
bubble.Text = reply.ToString();
StateHasChanged();
lastRender = Environment.TickCount64;
}
}
bubble.Text = reply.ToString(); // final flush, so nothing is lost
StateHasChanged();
Twenty frames a second still reads as smooth typing to a human and cuts the render work by most of an order of magnitude. The final flush outside the loop is not optional — without it the last few tokens sit in the buffer and the answer appears to stop mid-sentence.
When StateHasChanged throws
Calling StateHasChanged() directly is correct here because the streaming loop runs as the continuation of a component event handler, and those continuations come back on the renderer’s synchronization context. Move the same work somewhere else — a System.Threading.Timer, a background service pushing updates, an event handler on a singleton — and you get an InvalidOperationException telling you the current thread is not associated with the Dispatcher. The fix is await InvokeAsync(StateHasChanged), which marshals the call back onto the dispatcher. It is worth recognising the message on sight, because the code that triggers it usually looks identical to the code that works.
What goes wrong mid-stream
A streaming call has a longer window in which to fail than a single request-response, and the failures land in front of the user rather than in a log.
Rate limiting. A 429 from the provider can arrive before the first token or, on a busy deployment, part-way through. Without the try/catch above, the exception propagates out of the event handler and Blazor Server tears down the circuit — the user sees the reconnect overlay and loses the whole conversation over one throttled request. Catching it and writing a message into the bubble keeps the circuit and the history alive so a retry is one click.
The user navigating away. Without a CancellationToken, the model call carries on generating after the component is gone, and you pay for tokens nobody will read. Cancelling in Dispose (implement IDisposable and call _cts?.Cancel()) closes that off.
Double submission. The _busy guard plus the disabled input covers the obvious case. It matters more than it looks: a second concurrent call mutating _history while the first is still iterating it will throw a collection-modified exception from inside the streaming loop, which surfaces as an incomprehensible crash rather than as “you clicked twice”.
State lifetime, and the trap that leaks conversations
Registering IChatClient as a singleton is correct and shared safely. Conversation state is the opposite, and the mistake is easy to make: hoist _history into an injected service so it survives navigation, register that service as a singleton because it felt like infrastructure, and now every user on the server shares one conversation. In Blazor Server, a scoped service is scoped to the circuit — one user’s connection — so scoped is the registration you want for anything per-conversation. If the history needs to outlive the circuit, it needs a real store keyed by user, which is the subject of agent memory and state persistence.
The related detail is that a Blazor Server circuit holds all of this in server memory for as long as the user is connected. A long conversation is a growing List<ChatMessage> per user, and it is not only a memory number — it is also the prompt you re-send on every turn, so it is your token bill growing quietly in the background.
Why this is clean in Blazor
Notice what’s not here: no fetch, no JSON wrangling, no separate API endpoint, no client-side JavaScript. The component calls the model directly in C#, streams into a field, and Blazor’s rendering handles the UI. For Blazor Server, the streaming updates flow to the browser over the existing SignalR circuit automatically. (For a browser-to-server streaming pattern in other setups, see streaming responses to a web UI.)
Making it production-ready
A demo chatbot becomes a real feature when you add:
- A system prompt — seed
_messageswith aChatRole.Systemmessage to set the bot’s behaviour and scope. - Tools — let the bot do things, not just talk, by turning it into a tool-using agent.
- History limits — cap or summarize old turns so the prompt doesn’t grow unbounded (see agent memory and cost control).
- Auth and guardrails — a chatbot that calls tools is an attack surface; see securing AI agents.
Note:
Microsoft.Extensions.AIis still evolving, and details like whetherChatMessage.Textis mutable have changed between versions; verify against the current .NET AI docs for your package version. The pattern — injectIChatClient, accumulate the stream into a buffer,StateHasChangedas it grows — is the durable part.
Takeaway
Blazor makes an AI chatbot refreshingly simple: inject an IChatClient, render a message list, and repaint as the streamed reply grows — all in C#, no JavaScript. Keep two lists rather than one: a List<ChatMessage> that is exactly what you send the model, and a display list that can hold half-written replies, cancellations and errors. Set the render mode, pass a CancellationToken, catch the failure so a 429 doesn’t tear down the circuit, and throttle the repaint once you have more than a handful of concurrent users. Build it on IChatClient and you can point it at OpenAI, Azure, or a local model unchanged. Add a system prompt, tools, and history limits, and your “hello world” chatbot becomes a real product feature.
