Call your server services from Blazor WebAssembly as if they were local — no controllers, no HttpClient boilerplate, no duplicated DTO plumbing.
Define a C# service interface once. RemoteService.Net's Roslyn source generator does the rest:
- On the server (prerendering,
InteractiveServer, orInteractiveAutorunning server-side) the interface resolves to your real implementation and is called in-process. - In the browser (
InteractiveWebAssembly, orInteractiveAutoafter WASM takes over) the interface resolves to a generated HTTP proxy that calls generated Minimal API endpoints on the server.
Your components don't know the difference — they just inject the interface.
public interface IProductService : IRemoteService
{
Task<Product[]> SearchAsync(string query, CancellationToken cancellationToken = default);
}@inject IProductService Products
@code {
private Product[]? _results;
private async Task Search(string query)
=> _results = await Products.SearchAsync(query);
}That's it. No API controller, no route constants, no JsonSerializer calls, no manual DI wiring per service. Everything is generated at compile time — fully typed, refactor-safe, and AOT/trimming friendly.
Prefer request/handler style with pipeline behaviors? There's also a mediator flavor.
Blazor Web Apps with WebAssembly interactivity have a well-known friction point: components run in two places. During prerendering they execute on the server where they could call services directly; after hydration they execute in the browser where every data access must go over HTTP. The standard answer is to hand-write an API controller and a typed HttpClient wrapper and keep both in sync with the service interface — for every service.
RemoteService.Net removes that entire layer:
| Hand-rolled approach | RemoteService.Net |
|---|---|
| Write API controller per service | Generated Minimal API endpoints |
Write HttpClient wrapper per service |
Generated proxy per interface |
| Register each client + endpoint manually | One AddRemoteServiceClients() / MapRemoteServices() |
| Routes/DTOs drift out of sync | Compile-time generated from a single interface |
Errors arrive as raw HttpRequestException |
Typed exceptions round-trip (validation, 404, …) |
RemoteService.Net ships as three NuGet packages — one per layer of a typical Blazor Web App solution. Install each package in the project it belongs to.
The contracts library is referenced by both the client and the server project, so a single interface definition is shared across the whole solution.
Run each command from the directory of the project it applies to:
# Shared contracts project (class library)
dotnet add package RemoteService.Net.Abstractions
# Blazor WebAssembly client project
dotnet add package RemoteService.Net.Client
# ASP.NET Core server project
dotnet add package RemoteService.Net.ServerIf you prefer editing project files directly (or use Visual Studio's NuGet UI), add the reference to the matching project:
<!-- MyApp.Contracts.csproj — shared class library -->
<ItemGroup>
<PackageReference Include="RemoteService.Net.Abstractions" Version="1.2.0" />
</ItemGroup><!-- MyApp.Client.csproj — Blazor WebAssembly project -->
<ItemGroup>
<PackageReference Include="RemoteService.Net.Client" Version="1.2.0" />
<ProjectReference Include="..\MyApp.Contracts\MyApp.Contracts.csproj" />
</ItemGroup><!-- MyApp.csproj — ASP.NET Core server project -->
<ItemGroup>
<PackageReference Include="RemoteService.Net.Server" Version="1.2.0" />
<ProjectReference Include="..\MyApp.Contracts\MyApp.Contracts.csproj" />
</ItemGroup>Using Central Package Management? Put the
<PackageVersion Include="RemoteService.Net.Client" Version="1.2.0" />entries inDirectory.Packages.propsand drop theVersionattribute from thePackageReferenceelements above.
Right-click the project → Manage NuGet Packages… → Browse → search for RemoteService.Net → install the package listed for that project in the table above.
- Requires .NET 10.
- The source generator ships inside the Client and Server packages and detects its role automatically from which package a project references — no configuration needed. Override with the
RemoteServiceNetRoleMSBuild property if you ever need to (Client,Server, orClient,Server). RemoteService.Net.Abstractionsis pulled in transitively by both the Client and Server packages, so you only need to reference it explicitly in the shared contracts project.
Works with the standard Blazor Web App template using WebAssembly or Auto interactivity:
dotnet new blazor -int WebAssembly --all-interactive # or -int Autousing RemoteService.Net;
public sealed record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary);
public interface IWeatherService : IRemoteService
{
Task<WeatherForecast[]> GetForecastAsync(int days, CancellationToken cancellationToken = default);
}The only requirement: inherit the IRemoteService marker interface and return Task/Task<T>/ValueTask/ValueTask<T>.
public sealed class ServerWeatherService : IWeatherService
{
public async Task<WeatherForecast[]> GetForecastAsync(int days, CancellationToken ct = default)
{
// Talk to your database, other services, etc.
}
}// Program.cs (server)
builder.Services.AddScoped<IWeatherService, ServerWeatherService>();
var app = builder.Build();
// ...
app.MapRemoteServices(); // generated: maps POST /_rpc/{Interface}/{Method}MapRemoteServices() returns the route group holding every generated endpoint, so you can chain the usual Minimal API conventions to apply them to all RPC endpoints at once:
app.MapRemoteServices()
.RequireAuthorization() // protect every RemoteService endpoint
.RequireRateLimiting("rpc")
.WithMetadata(new SomeAttribute());Per-method [RemoteAllowAnonymous] still opts individual endpoints out of a group-level .RequireAuthorization().
// Program.cs (client)
builder.Services.AddScoped(_ => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress),
});
builder.Services.AddRemoteServiceClients(); // generated: registers a proxy per interface@page "/weather"
@inject IWeatherService WeatherService
@code {
[PersistentState] // .NET 10: reuse prerendered data instead of fetching twice
public WeatherForecast[]? Forecasts { get; set; }
protected override async Task OnInitializedAsync()
=> Forecasts ??= await WeatherService.GetForecastAsync(days: 5);
}During prerendering this calls ServerWeatherService directly. In the browser it becomes POST /_rpc/IWeatherService/GetForecastAsync. Combined with [PersistentState], the service is called exactly once per navigation.
| Render mode | What happens |
|---|---|
| Static SSR / prerendering | In-process call to the implementation |
InteractiveServer |
In-process call to the implementation |
InteractiveWebAssembly |
HTTP call via generated proxy |
InteractiveAuto |
In-process while server-side, HTTP once WASM takes over — automatically |
A complete runnable demo (weather + todos with validation) lives in samples/.
Throw a typed exception on the server; catch the same typed exception in the browser. On the wire it's a standard RFC 7807 ProblemDetails.
// Server implementation
public Task<TodoItem> AddAsync(string title, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title))
throw new RemoteValidationException("Validation failed.",
new Dictionary<string, string[]> { ["title"] = ["Title must not be empty."] });
// ...
}// Component — identical whether the call was in-process or HTTP
try
{
await TodoService.AddAsync(title);
}
catch (RemoteValidationException ex)
{
_errors = ex.Errors;
}| Throw on server | HTTP status | Caught in client |
|---|---|---|
RemoteValidationException |
400 | RemoteValidationException (with Errors) |
RemoteUnauthorizedException |
401 | RemoteUnauthorizedException |
RemoteNotFoundException |
404 | RemoteNotFoundException |
| any other exception | 500 | RemoteServerException (detail only in Development) |
MapRemoteServices() returns the RouteGroupBuilder that owns every generated RPC endpoint. Because it implements IEndpointConventionBuilder, all the familiar Minimal API extension methods can be chained onto it and apply to every endpoint at once — exactly like MapGet/MapPost:
app.MapRemoteServices()
.RequireAuthorization() // every RPC endpoint requires an authenticated user
.RequireRateLimiting("rpc")
.RequireCors("client")
.WithTags("rpc")
.AddEndpointFilter<AuditFilter>()
.CacheOutput(); // …and any other IEndpointConventionBuilder extensionThe return value is also an IEndpointRouteBuilder, so existing code that ignores it (app.MapRemoteServices();) keeps working unchanged.
app.MapRemoteServices().RequireAuthorization();That single line protects all current and future remote services — no attribute to forget when a new interface is added. Individual methods can still opt out:
public interface ISessionService : IRemoteService
{
Task<UserInfo> GetCurrentUserAsync(CancellationToken ct = default); // requires auth
[RemoteAllowAnonymous]
Task<bool> IsAliveAsync(CancellationToken ct = default); // reachable anonymously
}Group conventions are applied before the generated per-endpoint metadata, so [RemoteAllowAnonymous] wins over a group-level .RequireAuthorization(), and [RemoteAuthorize] adds its policy on top of it.
Interfaces can't carry [Authorize], so RemoteService.Net provides mirrored attributes that the generator translates to endpoint authorization:
[RemoteAuthorize(Roles = "admin")]
public interface IAdminService : IRemoteService
{
Task<AuditEntry[]> GetAuditLogAsync(CancellationToken ct = default);
[RemoteAllowAnonymous]
Task<bool> PingAsync(CancellationToken ct = default);
}Policy, Roles, and AuthenticationSchemes are supported, on the interface or per method. To require authorization for everything without annotating each interface, see Endpoint conventions above.
- Every method maps to
POST /_rpc/{InterfaceName}/{MethodName}— override with[RemoteRoute]/[RemoteMethod]. - Request body is a JSON object with one property per parameter. Generated types never appear on the wire, so AOT users only need a
JsonSerializerContextfor their own DTOs. Task<T>→200(including JSONnull);Task→204.CancellationTokenflows toHttpContext.RequestAborted.- A required custom header (
X-RemoteService-Call) blocks classic CSRF against cookie-authenticated endpoints. - A protocol version header detects stale cached WASM clients after a deployment and surfaces a clear
RemoteProtocolExceptioninstead of confusing serialization errors. - Overloads and generic interfaces/methods are rejected at compile time with descriptive diagnostics (
RSN001–RSN008).
Prefer request/handler style over service interfaces? RemoteService.Net also ships a mediator flavor (MediatR-like, but with a remote-aware design and no external dependency). A request record is the whole contract:
// Shared contracts project
public sealed record SearchProducts(string Term) : IRemoteRequest<IReadOnlyList<Product>>;
public sealed record RecordProductView(Guid ProductId) : IRemoteRequest; // fire-and-forget// Server project — the handler
public sealed class SearchProductsHandler : IRemoteRequestHandler<SearchProducts, IReadOnlyList<Product>>
{
public Task<IReadOnlyList<Product>> HandleAsync(SearchProducts request, CancellationToken ct) => ...;
}@inject IRemoteMediator Mediator
@code {
private async Task Search(string term)
=> _results = await Mediator.SendAsync(new SearchProducts(term));
}Same dual dispatch as the interface flavor: in-process on the server (prerendering,
InteractiveServer, InteractiveAuto server-side), POST /_rpc/mediator/{RequestName} from
the browser. Same error round-tripping, CSRF protection, and protocol versioning.
[RemoteAuthorize] / [RemoteAllowAnonymous] / [RemoteRoute] go on the request type.
| Package | Install in |
|---|---|
| Shared contracts class library | |
Blazor WebAssembly project (*.Client) |
|
| ASP.NET Core host project |
# Shared contracts project (class library)
dotnet add package RemoteService.Net.Mediator.Abstractions
# Blazor WebAssembly client project
dotnet add package RemoteService.Net.Mediator.Client
# ASP.NET Core server project
dotnet add package RemoteService.Net.Mediator.ServerThe mediator packages coexist with the interface-flavor packages — use either or both in the same app.
// Client Program.cs
builder.Services.AddRemoteMediator();
// Server Program.cs
builder.Services.AddRemoteMediatorHandlers(); // all handlers + in-process mediator
app.MapRemoteMediator(); // returns RouteGroupBuilder — chain
// .RequireAuthorization() etc. as usualEverything is source generated: dispatch is a compile-time switch over your request types
(no reflection, AOT-safe), and missing/duplicate handlers or route collisions are compiler
diagnostics (RSN010–RSN014).
Cross-cutting concerns wrap dispatch via IRemotePipelineBehavior<TRequest, TResponse>:
public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<TRequest> logger)
: IRemotePipelineBehavior<TRequest, TResponse>
where TRequest : IRemoteRequest<TResponse>
{
public async Task<TResponse> HandleAsync(
TRequest request, RemoteHandlerDelegate<TResponse> next, CancellationToken ct)
{
logger.LogInformation("Handling {Request}", typeof(TRequest).Name);
return await next();
}
}services.AddRemotePipelineBehavior(typeof(LoggingBehavior<,>)); // open generic: every request
services.AddRemotePipelineBehavior<SearchProducts, IReadOnlyList<Product>, SearchCacheBehavior>(); // one requestBehaviors belong to the container they're registered in. This is the key rule:
- Behaviors registered in the client
Program.csexist only in the browser and wrap the HTTP call — e.g. an in-browser memory cache or optimistic retry. - Behaviors registered in the server
Program.cswrap the handler — for HTTP-served requests and in-process calls during prerendering — e.g. validation,HybridCache, transactions. - Want the same behavior on both sides? Register it in both containers.
Registration order = execution order (first registered = outermost).
The demo's Products page shows a client-side cache behavior and a server-side timing behavior working together.
Interface (IRemoteService) |
Mediator (IRemoteRequest<T>) |
|
|---|---|---|
| Contract | Service interface with methods | One record per request |
| Feels like | Injected typed service | MediatR-style Send |
| Cross-cutting | Endpoint conventions / decorators | Pipeline behaviors (client + server) |
| Best when | Cohesive service APIs, easy mocking | CQRS-style apps, per-request pipelines |
dotnet build
dotnet test
dotnet pack -c Release -o artifacts/packages