Why build the same screen three times when one codebase can look and feel native on every device? If you’ve ever shipped a feature to web, then repeated yourself for iOS and Android (and maybe Windows/macOS), this post is your shortcut out of the repetition loop. I’ll show you how to deliver production‑grade, cross‑platform apps with Blazor Hybrid – sharing UI, business logic, and components – without giving up native capabilities or performance.
What exactly is Blazor Hybrid?
Blazor Hybrid hosts your Blazor components inside a native app using a WebView (via .NET MAUI on mobile/desktop). Unlike Blazor WebAssembly, your C# runs in‑process with full access to .NET and the device APIs – no sandbox, no network roundtrip to a server for rendering.
Think of it as:
+---------------------------+
| Native Shell (MAUI) | — navigation, windowing, status bar, push, sensors
| +---------------------+ |
| | BlazorWebView | | — renders Razor components
| | (in-process .NET) | |
| +----------+----------+ |
| | |
| DI/Services layer | — call device APIs from Razor via C# services
+-------------|-------------+
v
OS capabilities
When to choose it
- You already have Blazor components (Server or WASM) you want on mobile/desktop.
- You need one UI stack for Web + iOS + Android + Windows/macOS, but native device access too.
- You want hot reload, strong typing, and shared validation/business logic across all targets.
When not to
- You require pixel‑perfect native UI controls everywhere (e.g., platform guidelines above all).
- You must embed heavy 3D scenes or game loops in the WebView (consider native/OpenGL).
Project setup in 3 minutes
I’ll create a minimal app that shows device info and uses geolocation – end‑to‑end in C# only.
Create the solution
# install workloads once (if needed)
dotnet workload install maui
# new hybrid app
mkdir HybridCoffee && cd HybridCoffee
dotnet new maui-blazor -n HybridCoffee
Open in your IDE. You’ll see a MAUI app with a BlazorWebView
hosting wwwroot
and Razor components.
Register Blazor + device services
MauiProgram.cs
:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.Hosting;
using HybridCoffee.Services;
namespace HybridCoffee;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(f => f.AddFont("OpenSans-Regular.ttf", "OpenSans"));
builder.Services.AddMauiBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
#endif
// app services available to Razor via DI
builder.Services.AddSingleton<IDeviceInfoService, DeviceInfoService>();
builder.Services.AddSingleton<IGeolocationService, GeolocationService>();
return builder.Build();
}
}
Services/DeviceInfoService.cs
:
namespace HybridCoffee.Services;
public interface IDeviceInfoService
{
string Model { get; }
string Version { get; }
string Platform { get; }
}
public sealed class DeviceInfoService : IDeviceInfoService
{
public string Model => DeviceInfo.Current.Model;
public string Version => DeviceInfo.Current.VersionString;
public string Platform => DeviceInfo.Current.Platform.ToString();
}
Services/GeolocationService.cs
:
namespace HybridCoffee.Services;
public interface IGeolocationService
{
Task<Location?> GetOnceAsync(CancellationToken ct = default);
}
public sealed class GeolocationService : IGeolocationService
{
public async Task<Location?> GetOnceAsync(CancellationToken ct = default)
{
var request = new GeolocationRequest(GeolocationAccuracy.Medium, TimeSpan.FromSeconds(10));
return await Geolocation.Default.GetLocationAsync(request, ct);
}
}
Permissions: enable location permissions per platform (Android
AndroidManifest.xml
, iOSInfo.plist
). MAUI’s templates include comments; just toggle on what you need.
Build a Razor UI that calls native services
Pages/Index.razor
:
@page "/"
@inject IDeviceInfoService Device
@inject IGeolocationService Geo
<h1 class="title">HybridCoffee ☕</h1>
<p>Platform: <b>@Device.Platform</b> • Model: <b>@Device.Model</b> • OS: <b>@Device.Version</b></p>
<button class="btn" @onclick="GetLocation" disabled="@_busy">@(_busy?"Locating…":"Get location")</button>
@if (_error is not null)
{
<p class="error">@_error</p>
}
@if (_loc is not null)
{
<p>Lat: @_loc.Latitude:F5, Lon: @_loc.Longitude:F5</p>
}
@code {
bool _busy;
string? _error;
Location? _loc;
async Task GetLocation()
{
try
{
_busy = true; _error = null; _loc = null;
var l = await Geo.GetOnceAsync();
if (l is null) _error = "No location available."; else _loc = l;
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
}
<style>
.title { font-size:2rem; margin: .75rem 0; }
.btn { padding:.6rem 1rem; border-radius:.5rem; border:0; box-shadow:0 2px 6px rgba(0,0,0,.15); }
.error { color:#c0392b; }
</style>
Run on Android emulator, iOS simulator (Mac), Windows, or macOS. Same component, same C#, native capabilities.
Architecture: share what matters, isolate what doesn’t
In my project rulebook I follow a “shared core, thin shells” approach:
- Shared: Razor components, validators, DTOs, HTTP/GraphQL clients, domain rules, localization, theming.
- Per‑platform shells: permissions, background services, notifications, platform UI affordances (e.g., status bar, title bar).
Recommended solution layout
HybridCoffee/
├─ src/
│ ├─ HybridCoffee.App # .NET MAUI (hosts BlazorWebView)
│ ├─ HybridCoffee.UI # Razor Class Library (components + CSS isolation)
│ └─ HybridCoffee.Core # domain/services (no UI)
└─ tests/
├─ HybridCoffee.UI.Tests # bUnit
└─ HybridCoffee.Core.Tests # xUnit
Tip: Place as much code as possible in Core
and UI
so your MAUI project is mostly hosting + platform glue.
Reusing web components in hybrid
If you already ship a Blazor WebAssembly/Server app, extract components to a Razor Class Library (RCL) and reference it from both the web app and the MAUI host. Avoid browser‑only APIs in shared components; when unavoidable, hide them behind an interface.
Example: conditional compilation in an RCL
public interface IClipboard
{
Task SetTextAsync(string text);
}
public sealed class ClipboardService : IClipboard
{
#if ANDROID || IOS || WINDOWS || MACCATALYST
public Task SetTextAsync(string text) => Clipboard.SetTextAsync(text);
#else
// Web (WASM/Server) implementation via JS
private readonly IJSRuntime _js;
public ClipboardService(IJSRuntime js) => _js = js;
public async Task SetTextAsync(string text) => await _js.InvokeVoidAsync("navigator.clipboard.writeText", text);
#endif
}
Register the single abstraction in DI; the compiler picks the right implementation per TFM.
JS interop (the hybrid way)
You can still call JavaScript from Razor when it’s convenient (for WebView DOM tweaks), and call .NET from JS if needed.
Call .NET from JavaScript
// in a .NET class accessible from Razor (in an RCL)
using Microsoft.JSInterop;
public static class Bridge
{
[JSInvokable]
public static string Echo(string input) => $"You said: {input}";
}
<!-- wwwroot/index.html inside MAUI Blazor host -->
<script>
async function pingDotNet() {
const result = await DotNet.invokeMethodAsync('HybridCoffee.UI', 'Echo', 'Hello from JS');
document.getElementById('result').innerText = result;
}
</script>
<button @onclick="() => JS.InvokeVoidAsync("pingDotNet")">Ping .NET</button>
<p id="result"></p>
Call JavaScript from Razor remains the same (IJSRuntime
). Use sparingly in hybrid; most device features are easier via MAUI Essentials.
Navigation: Blazor Router + native Shell
You get two layers:
- Blazor Router for intra‑component navigation (
@page
+<NavLink>
) - MAUI Shell for native tabs, flyout, deep links, and windowing
Pattern I use: keep app navigation inside Blazor; use Shell for app‑level chrome (tabs/sections) and OS‑level intents.
AppShell.xaml
(MAUI):
<TabBar>
<Tab Title="Home" Icon="home.png">
<ShellContent ContentTemplate="{DataTemplate local:MainPage}" />
</Tab>
<Tab Title="Settings" Icon="settings.png">
<ShellContent ContentTemplate="{DataTemplate local:SettingsPage}" />
</Tab>
</TabBar>
MainPage.xaml
(hosts Blazor):
<BlazorWebView HostPage="wwwroot/index.html">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type ui:App}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
Then, inside the Razor App.razor
use the usual router:
<Router AppAssembly="typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
</Found>
<NotFound>
<p>Sorry, page not found.</p>
</NotFound>
</Router>
State & storage that works online and offline
Local state in hybrid can live in:
- Preferences (key/value, per platform)
- FileSystem.AppDataDirectory (files/JSON)
- SQLite (with EF Core or Dapper)
- In‑memory state containers for UI
I often wrap preferences + JS localStorage
behind one abstraction so the same razor code works in web & hybrid.
public interface IKeyValueStore
{
Task SetAsync(string key, string value);
Task<string?> GetAsync(string key);
}
public sealed class PreferencesStore : IKeyValueStore
{
public Task SetAsync(string key, string value)
{ Preferences.Default.Set(key, value); return Task.CompletedTask; }
public Task<string?> GetAsync(string key) => Task.FromResult(Preferences.Default.Get<string?>(key, null));
}
public sealed class WebLocalStorageStore : IKeyValueStore
{
private readonly IJSRuntime _js; public WebLocalStorageStore(IJSRuntime js) => _js = js;
public Task SetAsync(string key, string value) => _js.InvokeVoidAsync("localStorage.setItem", key, value).AsTask();
public async Task<string?> GetAsync(string key) => await _js.InvokeAsync<string?>("localStorage.getItem", key);
}
Register one or the other based on target framework or a config flag.
For sync‑on‑connect scenarios, keep a local queue of changes (SQLite) and reconcile with your backend when the network is available.
Performance playbook (what actually moves the needle)
From real projects:
- Trim & link: turn on publish trimming; keep a
LinkerDescriptor.xml
for reflection‑heavy libs. - AOT where it helps: Ahead‑of‑Time compile for Android can reduce CPU churn at the cost of APK size – measure.
- Reduce first paint: lazy‑load heavy components; keep your landing component tiny.
- Static assets: serve local files from
wwwroot
(no network), compress large images, prefer SVG. - Avoid chatty JS interop in tight loops; keep the hot path in C#.
- Use CSS isolation to avoid giant global stylesheets.
Profiling tip: on Android, use
adb
+logcat
alongside .NET traces; on desktop, PerfView + event pipe gives quick wins.
Styling: CSS isolation with native flavor
Hybrid means your UI is still HTML/CSS inside the WebView. Keep styles scoped.
Components/Counter.razor.css
:
.host { display:flex; gap:.75rem; align-items:center; }
.btn { padding:.5rem .9rem; border-radius:.6rem; border:0; box-shadow:0 2px 8px rgba(0,0,0,.12); }
<div class="host">
<button class="btn" @onclick="() => _count++">Clicked @_count</button>
<span>@_count</span>
</div>
You can still integrate design systems (e.g., Bootstrap/Tailwind) but ship only what you use – large CSS hurts startup.
Testing that doesn’t crumble on day two
- bUnit for components (fast, deterministic)
- xUnit/NUnit for domain services
- UI smoke with MAUI UITest or Appium for a few golden paths
HybridCoffee.UI.Tests/DeviceBadgeTests.cs
:
using Bunit;
using HybridCoffee.UI.Components;
public class DeviceBadgeTests : TestContext
{
[Fact]
public void RendersModel()
{
var cut = RenderComponent<DeviceBadge>(p => p.Add(x => x.Model, "Pixel"));
cut.Markup.Contains("Pixel");
}
}
Keep the majority of tests below the UI – services and view models. UI tests are costly; use them for must‑not‑break flows.
CI/CD: build once, ship everywhere
A pragmatic GitHub Actions matrix (simplified):
name: build-hybridcoffee
on: [push]
jobs:
build-android:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '8.0.x' }
- name: Install workloads
run: dotnet workload restore
- name: Build
run: dotnet build src/HybridCoffee.App/HybridCoffee.App.csproj -c Release -f net8.0-android
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '8.0.x' }
- name: Install workloads
run: dotnet workload restore
- name: Build
run: dotnet build src/HybridCoffee.App/HybridCoffee.App.csproj -c Release -f net8.0-windows10.0.19041.0
iOS signing requires a developer account and provisioning profiles; keep that pipeline on macOS with secrets for certificates.
Troubleshooting (from my actual scars)
- Blank screen? Check the
<RootComponent Selector>
matches the element id inwwwroot/index.html
. - Static files 404? Ensure
HostPage
points to the correctindex.html
and that assets are included in the project. - Permissions denied for sensors? Verify runtime prompts and app manifest entries on each platform.
- Slow first load on mobile? Remove unused CSS/JS, enable trimming, and avoid heavy images on the landing page.
- Interop errors (
DotNet is undefined
)? Wait for Blazor to boot before invoking; call from Razor afterOnAfterRenderAsync
.
Mini blueprint: from web Blazor to hybrid in a week
- Extract shared components into an RCL (
.razor
+ CSS isolation + i18n). - Abstract any browser‑only logic behind interfaces.
- Host the RCL in a MAUI Blazor app; wire DI for device services you need on day one (Preferences, Clipboard, Files, Geo).
- Map navigation: keep Razor routes; add native shell (tabs) only if it adds value.
- Tighten startup: cut CSS/JS, trim, defer heavy components.
- Deliver a vertical slice to TestFlight/Play Internal Track/Windows sideload.
- Iterate based on telemetry (crashes, warm/cold start times, tap heatmaps).
FAQ: Your top questions answered
No. MAUI is the native app framework; Blazor Hybrid is the UI model you host inside MAUI via BlazorWebView
.
Yes – move them to an RCL. Avoid browser‑only APIs or hide them behind DI + conditional compilation.
Yes. Your code runs in‑process. Use Preferences/SQLite for local state and sync when online.
Expect larger packages than pure native views (you ship a small web runtime + assets). Trim and remove unused CSS/JS.
Yes, via MAUI platform APIs or community libraries. Surface them to Razor through services.
For line‑of‑business apps and content‑heavy UIs, absolutely. Measure, trim, and keep JS interop minimal.
Publish via stores as usual. For inside‑app content (Razor/JSON), you can version assets and fetch newer content from your API if your policy allows.
Conclusion: Ship once, feel native
If your backlog screams “we’re building it again for another platform,” Blazor Hybrid lets you cut duplication while keeping the native feel users expect. Start with one vertical slice: a screen, a device capability, and a clean navigation story. Measure startup, trim what you don’t need, and you’ll be surprised how far one codebase takes you.
Your turn: what’s the first screen you’ll move to Blazor Hybrid – and which device feature will you wire up first? Drop a comment; I’m happy to review an architecture sketch.