One Blazor codebase, three platforms: how I built a quit-smoking app solo with .NET MAUI Hybrid
DEV Community

One Blazor codebase, three platforms: how I built a quit-smoking app solo with .NET MAUI Hybrid

The core bet: share the UI, not just the models

The obvious way to build web + mobile is to share your API and data models and rebuild the UI per platform. I didn't want to maintain three front ends alone, so I made a bigger bet: share the actual UI components.

The stack:

  • ASP.NET Core 8 Web API
  • Blazor WebAssembly for the web front end
  • .NET MAUI Blazor Hybrid for Android and iOS
  • PostgreSQL for data
  • SignalR for the real-time support chat
  • Azure for hosting (mostly free tiers)

The keystone is a Razor Class Library - Quitara.Shared - that holds essentially the entire UI: pages, components, the router, localization, API client, auth state. Both the Blazor WASM web app and the MAUI Hybrid app reference it and render the same components. The web app runs them in the browser's WASM runtime; the mobile app runs them in a BlazorWebView (a native web view hosting Blazor). Write a page once, it ships to all three platforms.

That only works if you're disciplined about one thing: the shared library can't know what platform it's on. Anything platform-specific hides behind an interface.

Seams: how the shared code stays platform-agnostic

The pattern that made this maintainable was defining small interfaces in the shared library and implementing them per host. The clearest example is token storage. The web app should keep the JWT in browser localStorage; the mobile app should use the OS secure storage (Keychain / Keystore). So the shared code depends on an abstraction:

public interface ITokenStorage
{
    Task<string?> GetTokenAsync();
    Task SetTokenAsync(string token);
    Task ClearTokenAsync();
}

The web project registers a localStorage-backed implementation; the MAUI project registers one backed by SecureStorage. The shared JwtAuthStateProvider just asks for ITokenStorage and never knows the difference.

The same seam pattern covers the things that genuinely differ per platform:

  • IPlatformInfo - so shared code can ask "am I in the native app?" (the web build shows a donation link and an install badge; the apps hide both, partly because the app stores frown on external payment links).
  • IGoogleSignInService / IAppleSignInService - native SDK auth on mobile, a redirect flow (or no-op) on web.
  • ICultureStorage, IAnonymousIdStorage - same idea.

Each interface is a handful of methods. The payoff is that 95% of the app lives in one place and behaves identically everywhere.

Real-time chat with SignalR

The support chat is the heart of the product, so it had to feel instant. SignalR handles it: a hub on the API, and a shared SignalRChatService in the class library that both front ends use identically. When a user requests help, supporters in the queue get a live event; when one accepts, both sides join a conversation group and messages flow in real time. Because the service is in the shared library, the web and mobile chat experiences are literally the same code.

One nice consequence of the JWT-in-abstraction approach: the SignalR connection's AccessTokenProvider just pulls from ITokenStorage, so auth on the socket works the same on every platform for free.

The iOS bug that cost me four builds

Here's the war story, because it's the kind of thing that eats a weekend and isn't well documented. Everything worked in the browser and on Android. Then the first iOS TestFlight build launched to a permanent blank screen - splash, then white, forever. No crash, no error UI, nothing.

Store-signed iOS builds don't attach a debugger, so step one was just getting any signal. I added an unhandled-exception handler that writes to the platform log, then read the device's syslog over USB with pymobiledevice3. The app process was alive, the web view was alive - but the Blazor UI never attached, and there was a telltale LaunchServices error: the app was trying to open its own initial page in Safari.

The cause: MAUI's BlazorWebView serves the app's content from a virtual host. On Android that host is 0.0.0.0. But since .NET 9, on iOS it's localhost / 0.0.0.1 - because browsers started dropping support for 0.0.0.0 (dotnet/maui#24363). My app had a UrlLoading handler that opened external links in the system browser, and it decided "external" by checking the host wasn't 0.0.0.0. On iOS, the app's own pages now had host localhost, so the handler ejected the app's first page load to Safari - and the web view sat empty forever.

The fix was one line of intent: whitelist all the framework's virtual hosts, not just Android's.

private static readonly HashSet<string> BlazorVirtualHosts = new(StringComparer.OrdinalIgnoreCase)
{
    "0.0.0.0",
    "0.0.0.1",
    "localhost"
};

private void BlazorWebView_UrlLoading(object? sender, UrlLoadingEventArgs e)
{
    if (!BlazorVirtualHosts.Contains(e.Url.Host))
        e.UrlLoadingStrategy = UrlLoadingStrategy.OpenExternally;
}

Lessons that generalize: wire up platform-log exception reporting before your first store build (you can't attach a debugger later), and never assume a framework default is the same across platforms - the same MAUI control had a different virtual host on iOS than on Android, and nothing warned me. (Bonus gotcha from the same saga: App Store Connect requires builds made with the iOS 26 SDK, which means pinning the exact Xcode version and the matching .NET-for-iOS workload set together - mismatches fail late and cryptically.)

Boring by design: timezones

One more that bit me and might save you a bug report. Streaks and daily check-ins were computed in UTC. Fine in testing - until a user in California checked in at 8pm and watched their streak reset, because 8pm Pacific is already "tomorrow" in UTC. Day-based features have to resolve "today" in the user's zone: profile timezone first, then a client-sent offset, then UTC as a last resort. If your app has streaks, check-ins, or "days since," don't do date math in UTC.

What it costs to run

Solo and free-to-users means the hosting bill matters. The whole thing runs at roughly $30/month, almost all of it the managed Postgres instance - the API is on an App Service free tier and the Blazor WASM web app is on a free Static Web App. A two-sided support product on the price of a couple of lunches.

Would I make the same bet again?

Yes. The shared-UI approach was the right call for a solo dev: one place to fix a bug, one place to add a feature, three platforms updated. MAUI Blazor Hybrid is real and it works - but budget for the platform edges. The 95% that's shared is a joy; the 5% that isn't (signing, store SDKs, per-platform web-view quirks) is where the weekends go. Knowing that going in, I'd choose it every time over maintaining three front ends alone.

If you want to see the result, it's live and free - no ads, no subscription, web + Android + iOS, in four languages: https://www.quitara.app

And if you've quit smoking yourself and want to be the person someone on day 2 gets to talk to, supporter signup takes two minutes. That's the part no framework can build. Happy to answer anything about the stack, the shared-UI approach, or the cold-start problem of a two-sided support network - that last one's harder than the code.

Comments

No comments yet. Start the discussion.