Multi-Tenant SaaS .NET products such as the Indotalent CRM Multitenant are built on a Blazor Server front end organized as vertical slices. Every feature is a slice: a command, a handler, and a MudBlazor component that stays in one folder. Tenancy cuts across the whole app, so the design goal is to let tenant context flow from authentication into every slice without threading a tenantId parameter through every method signature. A Scoped TenantContext plus EF Core global query filters achieves that, and Blazor Server's circuit-scoped services do most of the plumbing.
Why Blazor Server Works for Multi-Tenant SaaS .NET
Blazor Server keeps a persistent connection between the browser and the server. Each circuit gets its own DI scope, which is exactly the granularity tenancy needs: a long-lived scope that belongs to one logged-in user. Data access stays server-side, so EF Core query filters run where the data lives. MudBlazor provides the component library, and JWT authentication protects every interaction.
The flip side is that a circuit can outlive a single HTTP request. Authentication state, tenant context, and scoped services must be established when the circuit is created and refreshed deliberately, not assumed per page.
The same circuit also means UI events, data queries, and SignalR reconnection all run inside one authenticated scope. When the circuit reconnects after a network drop, Blazor Server recreates the services from the same registration, so the tenant context is restored consistently rather than rebuilt per page.
Tenant-Aware DI: Scoped Context, Scoped DbContext
The DI registration is the heart of tenancy. TenantContext is Scoped, AppDbContext is Scoped, and the DbContext factory reads the tenant's connection string from the context. The registration below resolves a fresh context per circuit and per request.
builder.Services.AddScoped<TenantContext>();
builder.Services.AddScoped(sp =>
{
var tenant = sp.GetRequiredService<TenantContext>();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(tenant.ConnectionString)
.Options;
return new AppDbContext(options, tenant);
});
Nothing is static and nothing is shared between tenants. Each scope resolves its own context, and the context's global query filters close over the tenant that owns the scope. A tenant switch, a logout, or a circuit teardown releases the whole graph.
This factory pattern is the same one a database-per-tenant deployment uses. Because the connection string is read from the TenantContext, you can switch a single tenant from the shared database to their own database by changing one row in the tenant store; nothing in the feature slices changes.
Vertical Slices in a Multi-Tenant SaaS .NET App
Inside the slice, the handler never receives a tenantId parameter. It depends on AppDbContext and TenantContext, and the global query filter does the rest. The ListContacts slice below is a complete feature: query, handler, and data access in one file.
// Features/Contacts/ListContacts.cs
public sealed record ListContactsQuery(int Page, int PageSize)
: IRequest<PagedResult<ContactDto>>;
public sealed class ListContactsHandler
: IRequestHandler<ListContactsQuery, PagedResult<ContactDto>>
{
private readonly AppDbContext _db;
public ListContactsHandler(AppDbContext db) => _db = db;
public async Task<PagedResult<ContactDto>> Handle(
ListContactsQuery query, CancellationToken ct)
{
var rows = await _db.Contacts
.OrderBy(c => c.Name)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.Select(c => ContactDto.FromEntity(c))
.ToListAsync(ct);
return new PagedResult<ContactDto>(rows, query.Page);
}
}
The handler does not filter by tenant at all, because the AppDbContext already does. The query runs against the tenant's filtered view of the Contacts table. This is the payoff of structural tenancy: feature code looks single-tenant while the framework guarantees isolation.
Circuit Scope: The Blazor Server Twist
The one thing that surprises developers moving from MVC is the circuit. In a minimal API, a scope lives for one request. In Blazor Server, it lives for the circuit, which can stay open for minutes. That is convenient, but it means tenant context must be set when the user authenticates, not lazily on first page load.
- Set TenantContext when the authentication state changes, not inside a component's OnInitializedAsync
- Re-scope on tenant switch and clear it on logout
- Never cache a DbContext across circuits or in static fields
- Let the circuit scope own the TenantContext for the whole session
As a rule of thumb, treat the circuit scope as a session, not a request. Components come and go as the user navigates, but the scope persists. Tenancy therefore belongs to the scope, which is created with the circuit and released when the circuit ends.
Key Takeaways
- Blazor Server circuits give you a natural scope for tenant context
- Register TenantContext and AppDbContext as Scoped and let the factory read the tenant's connection string
- Global query filters let vertical slices look single-tenant while isolation stays structural
- Set tenant context at authentication, not on page load
- Indotalent's SaaS CRM and HRM show this exact Blazor Server + VSA tenancy in real .NET 10 source code
FAQ
Does Blazor WebAssembly work for multi-tenant SaaS? It can, but EF Core query filters run on the server, so WebAssembly pushes data access into an API anyway. Blazor Server keeps the tenant boundary inside the host process.
How do I switch tenants at runtime? The safe route is to end the circuit or re-authenticate with a new current-tenant claim. Re-scoping the TenantContext mid-session is possible but easy to get wrong.
Do MudBlazor components need tenant awareness? No. Tenancy lives in service resolution and data access. Components simply render what the tenant-filtered queries return.
Can I use this tenancy with a REST API too? Yes. The same TenantContext, DI registration, and query filters serve minimal API endpoints and Blazor Server circuits in one application.