<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hiren Joshi</title>
    <description>The latest articles on DEV Community by Hiren Joshi (@hiren_joshi_9e71b556fda58).</description>
    <link>https://dev.to/hiren_joshi_9e71b556fda58</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4035551%2F5e302fed-4a14-4a40-a4d2-a523ed4b7b2f.png</url>
      <title>DEV Community: Hiren Joshi</title>
      <link>https://dev.to/hiren_joshi_9e71b556fda58</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hiren_joshi_9e71b556fda58"/>
    <language>en</language>
    <item>
      <title>One Blazor codebase, three platforms: how I built a quit-smoking app solo with .NET MAUI Hybrid</title>
      <dc:creator>Hiren Joshi</dc:creator>
      <pubDate>Sat, 18 Jul 2026 16:31:08 +0000</pubDate>
      <link>https://dev.to/hiren_joshi_9e71b556fda58/one-blazor-codebase-three-platforms-how-i-built-a-quit-smoking-app-solo-with-net-maui-hybrid-5e10</link>
      <guid>https://dev.to/hiren_joshi_9e71b556fda58/one-blazor-codebase-three-platforms-how-i-built-a-quit-smoking-app-solo-with-net-maui-hybrid-5e10</guid>
      <description>&lt;p&gt;I quit smoking three times before it stuck. The relapses taught me something no tracking app ever addressed: a craving is a five-minute emergency, and in those five minutes a progress chart is useless. What helps is a person - ideally one who's been there.&lt;/p&gt;

&lt;p&gt;Every quit-smoking app I tried gave me a streak counter and a badge. None of them gave me a human at 11pm. So a few months ago, a year into the quit that finally held, I built the thing I'd wanted: &lt;strong&gt;Quitara&lt;/strong&gt;, a free platform where you tap one button during a craving and get connected to a volunteer supporter - often an ex-smoker - in a private real-time chat.&lt;/p&gt;

&lt;p&gt;This post is the technical story of building it solo across web, Android, and iOS without writing the UI three times. If you're a .NET dev who's eyed MAUI Blazor Hybrid and wondered whether the "one codebase" promise is real, here's an honest field report - including the bug that cost me four App Store builds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core bet: share the UI, not just the models
&lt;/h2&gt;

&lt;p&gt;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: &lt;strong&gt;share the actual UI components.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ASP.NET Core 8&lt;/strong&gt; Web API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blazor WebAssembly&lt;/strong&gt; for the web front end&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;.NET MAUI Blazor Hybrid&lt;/strong&gt; for Android and iOS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; for data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SignalR&lt;/strong&gt; for the real-time support chat&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure&lt;/strong&gt; for hosting (mostly free tiers)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The keystone is a &lt;strong&gt;Razor Class Library&lt;/strong&gt; - &lt;code&gt;Quitara.Shared&lt;/code&gt; - 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 &lt;code&gt;BlazorWebView&lt;/code&gt; (a native web view hosting Blazor). Write a page once, it ships to all three platforms.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Seams: how the shared code stays platform-agnostic
&lt;/h2&gt;

&lt;p&gt;The pattern that made this maintainable was defining small interfaces in the shared library and implementing them per host.&lt;/p&gt;

&lt;p&gt;The clearest example is token storage. The web app should keep the JWT in browser &lt;code&gt;localStorage&lt;/code&gt;; the mobile app should use the OS secure storage (Keychain / Keystore). So the shared code depends on an abstraction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ITokenStorage&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetTokenAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;SetTokenAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ClearTokenAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The same seam pattern covers the things that genuinely differ per platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;IPlatformInfo&lt;/code&gt;&lt;/strong&gt; - 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).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;IGoogleSignInService&lt;/code&gt; / &lt;code&gt;IAppleSignInService&lt;/code&gt;&lt;/strong&gt; - native SDK auth on mobile, a redirect flow (or no-op) on web.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ICultureStorage&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;IAnonymousIdStorage&lt;/code&gt;&lt;/strong&gt; - same idea.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each interface is a handful of methods. The payoff is that 95% of the app lives in one place and behaves identically everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-time chat with SignalR
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;SignalRChatService&lt;/code&gt; 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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The iOS bug that cost me four builds
&lt;/h2&gt;

&lt;p&gt;Here's the war story, because it's the kind of thing that eats a weekend and isn't well documented.&lt;/p&gt;

&lt;p&gt;Everything worked in the browser and on Android. Then the first iOS TestFlight build launched to a &lt;strong&gt;permanent blank screen&lt;/strong&gt; - splash, then white, forever. No crash, no error UI, nothing.&lt;/p&gt;

&lt;p&gt;Store-signed iOS builds don't attach a debugger, so step one was just getting &lt;em&gt;any&lt;/em&gt; signal. I added an unhandled-exception handler that writes to the platform log, then read the device's syslog over USB with &lt;code&gt;pymobiledevice3&lt;/code&gt;. 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 &lt;strong&gt;in Safari&lt;/strong&gt;.&lt;/p&gt;

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

&lt;p&gt;The fix was one line of intent: whitelist &lt;em&gt;all&lt;/em&gt; the framework's virtual hosts, not just Android's.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;HashSet&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;BlazorVirtualHosts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StringComparer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrdinalIgnoreCase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"0.0.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"0.0.0.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"localhost"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;BlazorWebView_UrlLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UrlLoadingEventArgs&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;BlazorVirtualHosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UrlLoadingStrategy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;UrlLoadingStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OpenExternally&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lessons that generalize: &lt;strong&gt;wire up platform-log exception reporting before your first store build&lt;/strong&gt; (you can't attach a debugger later), and &lt;strong&gt;never assume a framework default is the same across platforms&lt;/strong&gt; - the same MAUI control had a different virtual host on iOS than on Android, and nothing warned me.&lt;/p&gt;

&lt;p&gt;(Bonus gotcha from the same saga: App Store Connect requires builds made with the iOS 26 SDK, which means pinning the exact Xcode version &lt;em&gt;and&lt;/em&gt; the matching .NET-for-iOS workload set together - mismatches fail late and cryptically.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Boring by design: timezones
&lt;/h2&gt;

&lt;p&gt;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 &lt;em&gt;user's&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs to run
&lt;/h2&gt;

&lt;p&gt;Solo and free-to-users means the hosting bill matters. The whole thing runs at roughly &lt;strong&gt;$30/month&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I make the same bet again?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you want to see the result, it's live and free - no ads, no subscription, web + Android + iOS, in four languages: &lt;strong&gt;&lt;a href="https://www.quitara.app" rel="noopener noreferrer"&gt;https://www.quitara.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>blazor</category>
      <category>maui</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
