Sharing Blazor Components Between Web and .NET MAUI Hybrid Apps
Put your Blazor UI in a Razor Class Library and run it on both web and .NET MAUI Hybrid — project structure, DI seams, static assets, and the pitfalls I hit.
To share Blazor components between a web app and a .NET MAUI Blazor Hybrid app, put your pages, components, and view logic in a Razor Class Library (RCL), hide every platform-specific concern behind an interface, and reference the RCL from both a Blazor web head and a MAUI Hybrid head. The two host projects stay thin — routing shell, DI registrations, platform glue — and the RCL owns the actual product. My final-year pet healthcare platform runs exactly this way: one shared Blazor UI on both a MAUI Hybrid mobile app and Blazor WebAssembly, and the freelance MAUI Hybrid apps I build for clients use the same shared-component approach paired with offline-friendly SQLite storage.
Here is the structure, the seams, and the parts that will bite you if you get them wrong.
The solution layout
Three projects. Not two, not five.
MySolution/
├── src/
│ ├── App.Shared/ # Razor Class Library — pages, components, view logic
│ ├── App.Web/ # Blazor Web App head (Server, WASM, or Auto)
│ └── App.Mobile/ # .NET MAUI Blazor Hybrid head
App.Shared is a Razor Class Library using the Razor SDK:
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.*" />
</ItemGroup>
</Project>
Both heads add a plain ProjectReference to it. The critical rule: the RCL targets net10.0 only — never net10.0-android or any platform TFM, and it must not reference MAUI packages. The moment the shared library knows about a platform, sharing is over.
Routing lives in the heads, but the routable pages live in the RCL. Each head’s router just needs to be told where to look:
<Router AppAssembly="typeof(App).Assembly"
AdditionalAssemblies="new[] { typeof(App.Shared._Imports).Assembly }">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
</Found>
</Router>
The MAUI head hosts everything inside a BlazorWebView in MainPage.xaml, pointing at its own wwwroot/index.html; the web head uses its normal App.razor. Both render the same components. Microsoft’s docs on consuming components from class libraries cover the mechanics in more depth.
What goes in the shared library — and what stays out
The split that has held up across every project I have applied it to:
Shared (RCL): pages, components, layouts, form models and validation, client-side view logic, DTOs, the interfaces for platform services, scoped CSS, images and icons the UI needs.
Platform-specific (heads): navigation shell and deep-link handling, storage implementations, push and local notifications, camera/GPS/sensor access, authentication token acquisition, anything that touches Microsoft.Maui.* or HttpContext.
A useful test before adding a file to the RCL: could this code compile and behave sensibly in a browser tab and inside an Android WebView? Validation logic — yes. A component that renders an appointment card — yes. Code that writes to FileSystem.AppDataDirectory — no, that is a MAUI API, so it goes behind an interface.
Notice what is not on either list: your domain and data-access layers. Those sit below the RCL entirely. On the web the components typically call an API or injected services; on mobile they may call a local SQLite store. Both arrive through the same interfaces, which brings us to the seam that makes all of this work.
Platform services behind interfaces: the DI seam
Every platform capability the shared UI needs gets an interface in the RCL and an implementation per head. Storage is the canonical example:
// App.Shared/Services/IAppStorage.cs
public interface IAppStorage
{
Task SetAsync(string key, string value);
Task<string?> GetAsync(string key);
Task RemoveAsync(string key);
}
The MAUI implementation uses platform storage:
// App.Mobile/Services/MauiAppStorage.cs
public sealed class MauiAppStorage : IAppStorage
{
public Task SetAsync(string key, string value)
=> SecureStorage.Default.SetAsync(key, value);
public async Task<string?> GetAsync(string key)
=> await SecureStorage.Default.GetAsync(key);
public Task RemoveAsync(string key)
{
SecureStorage.Default.Remove(key);
return Task.CompletedTask;
}
}
The web implementation uses JS interop over localStorage (or ProtectedLocalStorage on Blazor Server):
// App.Web/Services/BrowserAppStorage.cs
public sealed class BrowserAppStorage(IJSRuntime js) : IAppStorage
{
public async Task SetAsync(string key, string value)
=> await js.InvokeVoidAsync("localStorage.setItem", key, value);
public async Task<string?> GetAsync(string key)
=> await js.InvokeAsync<string?>("localStorage.getItem", key);
public async Task RemoveAsync(string key)
=> await js.InvokeVoidAsync("localStorage.removeItem", key);
}
One warning: the two implementations do not carry the same guarantees — SecureStorage is encrypted at rest, while localStorage is plaintext and readable by any script on the page. Keep secrets and tokens out of this interface on the web, or back the web implementation with ProtectedLocalStorage or a server-side session instead.
Each head registers its own implementation:
// MauiProgram.cs
builder.Services.AddMauiBlazorWebView();
builder.Services.AddSingleton<IAppStorage, MauiAppStorage>();
// Program.cs (web)
builder.Services.AddScoped<IAppStorage, BrowserAppStorage>();
Components in the RCL only ever see IAppStorage. The same pattern covers notifications (IAppNotifier — local notifications on MAUI, a toast component on web), connectivity checks, file pickers, and share sheets. In the pet healthcare platform, the appointment booking and vet consultation screens come from that one shared UI on both WASM and the phone — and this kind of seam is exactly where a concern like reminders belongs, so each head can deliver them its own way.
Keep the interfaces small and intent-shaped (IAppNotifier.NotifyAppointmentReminder(...)) rather than mirroring a platform API surface. If an interface starts sprouting members only one platform implements, that is the design telling you the feature belongs in the head, not the shared library.
Static assets and CSS
An RCL can carry its own wwwroot, and both heads serve it under a _content path. If App.Shared contains wwwroot/img/logo.svg, both apps reference it as:
<img src="_content/App.Shared/img/logo.svg" alt="Logo" />
Scoped CSS (MyComponent.razor.css) is bundled automatically per library. The heads pull it in with one stylesheet link — in the web head’s App.razor and the MAUI head’s wwwroot/index.html:
<link rel="stylesheet" href="_content/App.Shared/App.Shared.bundle.scp.css" />
Two practical rules I follow. First, all UI-owned assets — icons, illustrations, component styles — live in the RCL’s wwwroot, so a head cannot drift visually. Second, always use relative or _content-prefixed paths, never absolute /img/... paths; the MAUI BlazorWebView serves from a virtual origin (https://0.0.0.0/ on most platforms) and absolute paths that happen to work in the browser can 404 inside the WebView.
Global theme tokens (CSS custom properties) also belong in an RCL stylesheet. The heads may layer on platform tweaks — safe-area insets on mobile, hover states on web — but the design system has one home.
Pitfalls that will bite you
JS interop is not the same runtime
A BlazorWebView is an embedded WebView, not a full browser session. There are no service workers, no browser extensions, downloads and window.open behave differently, and permission prompts (clipboard, geolocation) follow platform rules rather than browser rules. Any JavaScript the shared components rely on must be tested inside the WebView on a real device, not just in Chrome. My default: keep JS in the RCL to a minimum, and when a capability differs meaningfully per platform, promote it out of JS entirely and into a platform service interface where the MAUI head can use a native API instead.
File paths and storage
There is no writable wwwroot at runtime on mobile. Anything the app writes — the SQLite database, cached images, exported files — goes to FileSystem.AppDataDirectory on MAUI, and that API must never appear in the RCL. Resolve paths in the head, pass them in through configuration or a service. The full wiring for this is in my offline SQLite storage walkthrough.
Prerendering runs twice — but only on the web
Blazor Server and the .NET 8+ Web App template prerender by default, so OnInitializedAsync can execute twice per page load. MAUI Hybrid never prerenders. A shared component that fires a side effect on initialization — logging a visit, posting analytics, starting a timer — will behave differently per head unless you guard it (do side effects in OnAfterRenderAsync on first render). This one is nasty because each head looks correct in isolation.
Render modes belong to the heads
Do not hard-code @rendermode InteractiveServer inside RCL components. Interactivity decisions are per-host: the MAUI WebView is always interactive, while the web head might be Server, WASM, or Auto — a choice I break down in Blazor Server vs WebAssembly vs Hybrid. Let each head apply render modes at its root or per route, and write shared components to work under any of them.
When not to share
Shared UI is a means, not a goal. I would keep screens out of the RCL when:
- The UX is genuinely platform-shaped. A camera-first capture flow or a map-centric screen usually deserves a native (or at least head-local) implementation rather than a lowest-common-denominator page.
- The web page exists for SEO. Marketing and content pages need prerendered HTML, structured data, and fast static delivery — concerns a mobile app never has. Share the components inside them if useful, not the pages.
- The app is small and single-platform for the foreseeable future. An RCL plus interface seams is real overhead; for a web-only admin panel it buys nothing.
A reasonable heuristic: share the product surface (forms, lists, dashboards, workflows), keep the shell and the platform-flavored edges per head.
Where this pays off
The economics are the point. Once the RCL exists, every feature is written once, reviewed once, and fixed once — and shipping the same product on web and mobile stops being two projects. That is the model behind the pet healthcare platform and the client apps I build; if you are weighing this architecture for your own product, that is exactly the kind of work I take on. Microsoft’s Blazor Hybrid documentation is the right reference to keep open while you set up the first head.
Start with the three-project layout, move one real page into the RCL, and let the compiler errors show you your platform seams — each one is an interface waiting to be extracted.