Add an in-app notification inbox to a Blazor app
A drop-in, real-time notification inbox for Blazor Server or WebAssembly: install one component, mint a scoped token on your backend, and you get unread counts, read state, and SignalR push without building any of it.
The in-app inbox — the bell icon with a dropdown of notifications, an unread badge, and a read state that survives a refresh — looks simple and is not. Building it yourself means persisting notifications per user, pushing new ones in real time, tracking read state, reconciling optimistic UI with the server, and handling reconnects. This tutorial adds all of that to a Blazor app with Notavia in about fifteen minutes, using the Notavia.Inbox.Blazor package.
Note. The package installs from nuget.org, but Notavia is not yet open for self-serve sign-up — you will need access before the component has a service to talk to.
What you'll build
A Blazor page with a live notification bell. New notifications arrive over a SignalR connection with no refresh, the unread count updates in real time, and clicking an item marks it read and navigates to its action URL. The same component works in Blazor Server and Blazor WebAssembly.
Prerequisites
- A Notavia workspace with an inbox signing key (
nsi_…) and a publishable key (npk_test_…ornpk_live_…). Both are in your console under API keys. - A Blazor app targeting .NET 8 or .NET 10.
- Five minutes.
One idea before you start: there are two keys, and they belong in two different places. The publishable key is safe to ship to the browser — it only identifies your tenant and environment. The signing key is a secret and must never leave your server. The browser never holds a long-lived credential; instead your backend mints a short-lived, per-user token.
Step 1 — Install the component
dotnet add package Notavia.Inbox.Blazor
This is a thin Razor wrapper around Notavia's notifyservice-inbox web component. It mounts the element, bridges its browser events to EventCallback<T> parameters, and disposes the SignalR connection cleanly when the circuit ends.
Step 2 — Mint a delegated token on your backend
The component needs a token, and that token must be minted server-side so your signing key stays secret. Add a minimal-API endpoint that mints a short-lived JWT scoped to the signed-in user:
// Program.cs
using NotifyService.Sdk.InboxTokens; // NuGet package: Notavia.Sdk
app.MapGet("/api/inbox-token", (HttpContext http, IConfiguration config) =>
{
var organizationId = Guid.Parse(config["Notify:OrganizationId"]!);
string signingKey = config["Notify:InboxSigningKey"]!; // nsi_… — stays on the server
// Scope the token to YOUR authenticated user — never to a query string.
string externalUserId = http.User.FindFirst("sub")?.Value ?? "u_demo";
string token = InboxTokens.Mint(new InboxTokenOptions
{
OrganizationId = organizationId,
ExternalUserId = externalUserId,
SigningKey = signingKey,
Ttl = TimeSpan.FromMinutes(15),
});
return Results.Text(token);
}).RequireAuthorization();
Two things worth calling out. First, the token is scoped to externalUserId — the identifier for the end user in your system — and that is what decides whose inbox they see. Derive it from your own auth (the sub claim above); never from a query parameter a user could change. Second, the TTL is short. The component refreshes it for you — that's the next step.
Keep the secrets out of source control:
dotnet user-secrets set "Notify:OrganizationId" "<your-org-guid>"
dotnet user-secrets set "Notify:InboxSigningKey" "nsi_…"
Step 3 — Drop the inbox into a page
@page "/app"
@using NotifyService.Inbox.Blazor
@inject NavigationManager Navigation
@inject HttpClient Http
<NotifyServiceInbox
PublishableKey="@PublishableKey"
Token="@_token"
Variant="InboxVariant.Bell"
Theme="InboxTheme.Light"
RefreshToken="GetTokenAsync"
OnItemClick="HandleClick"
OnUnreadCountChanged="count => _unread = count" />
@code {
const string PublishableKey = "npk_test_…";
string _token = "";
int _unread;
protected override async Task OnInitializedAsync()
=> _token = await GetTokenAsync();
async Task<string> GetTokenAsync()
=> await Http.GetStringAsync("/api/inbox-token");
Task HandleClick(InboxItemClickedEventArgs e)
{
if (e.ActionUrl is not null)
Navigation.NavigateTo(e.ActionUrl);
return Task.CompletedTask;
}
}
That is the whole integration. RefreshToken is the parameter that earns its keep: the component calls GetTokenAsync whenever its token is about to expire, so a user who leaves a tab open for an hour keeps receiving notifications with no refresh logic from you. OnUnreadCountChanged fires on every change — optimistic, pushed, or reconciled — so you can render your own badge elsewhere if you want one.
Variant controls presentation: Bell is the dropdown shown here; Panel and List render an inline surface for a dedicated notifications page.
Step 4 — Send something to the inbox
With the component mounted, send a notification on the in-app channel to the same externalUserId:
curl -X POST https://<your-notify-host>/v1/notifications \
-H "Authorization: Bearer nsk_test_…" \
-H "Content-Type: application/json" \
-d '{
"channel": "in_app",
"recipient": { "external_user_id": "u_demo" },
"template": "welcome",
"data": { "first_name": "Ada" }
}'
(Exact request fields are in the API reference.)
It appears in the bell within a second, pushed over SignalR — no polling, no refresh. The unread badge increments; clicking the item marks it read and, if the template set an action URL, navigates there.
Theming
The component renders a plain custom element, so you style it with CSS custom properties on your host page — no props to thread through:
notifyservice-inbox {
--notify-color-primary: #5b5ef6; /* your brand color */
--notify-radius: 8px;
--notify-font-family: "Inter", sans-serif;
}
Common gotchas
- Publishable vs signing key. If you see auth errors in the browser, you have likely shipped the signing key to the client. Only
npk_…belongs in the browser;nsi_…stays on the server behind the token endpoint. - Token scope. The inbox shows exactly the notifications for the
externalUserIdyou minted the token for. An empty inbox usually means the send targeted a different id. - Blazor Server disposal. The component implements
IAsyncDisposableand tears down its SignalR connection on circuit end;JSDisconnectedExceptionis swallowed for you, so you don't need a try/catch around teardown. - Self-hosted or staging. Point the component at the right host with the
BaseUrlparameter; it defaults tohttps://api.notavia.saas-infrastructure.com.
What to read next
The same externalUserId and template can fan out to email, SMS, and chat from a single send — the inbox is just one channel. If you build with an AI editor, Notavia also ships an MCP server so your agent can wire this up for you. The free tier covers 1,000 sends a month, though sign-up is not open yet.