1. Executive Summary
The Core Problem: The "Thick Client" Trap
In traditional mobile development, the application binary contains both the Presentation Layer (UI) and the Business Logic Layer (Rules, Validation, Math). If a critical logic bug is discovered or a UI layout change is needed, a new binary must be compiled, signed, submitted to the App Store, and reviewed. This process takes 24-72 hours.
The Solution
We adopt a Server-Driven UI (SDUI) architecture combined with a Thin Client model.
- The Server: Acts as the Operating System. It defines the layout, calculates the data, and dictates navigation flow.
- The Client: Is a "dumb" rendering engine. It possesses a library of high-performance native components (the "Lego blocks") but knows neither where to put them nor what they mean until the server instructs it.
- Example of a SDUI framework example, a high level documentation that goes in depth on SDUI.
Industry Context
This architecture is the standard for high-scale applications requiring agility:
- Netflix: Uses SDUI to A/B test artwork and reorder content rows dynamically without app updates.
- Airbnb: Defines listing details pages via JSON, allowing instant addition of "Safety" badges globally.
- Spotify: Dynamically alters the "Home" tab to prioritize podcasts vs. music based on user habits via server-side JSON.
Case Study: FanApp. Our application must transition seamlessly between "Game Day Mode" (Live Scores/Tickets) and "Off-Season Mode" (News/Shop) instantly, while fixing business logic bugs on the server without user intervention.
2. Architecture Comparison
Why choose SDUI over standard Blazor Hybrid or a WebView wrapper? This table outlines the trade-offs.
| Feature | Standard Blazor Hybrid | WASM in WebView (Wrapper) | FanApp SDUI (Recommended) |
|---|---|---|---|
| Startup Speed | ⚡ Instant (Native DLLs) | 🐢 Slow (Downloads WASM) | ⚡ Instant (Native DLLs) |
| Performance | 🚀 Native Speed | ⚠️ Browser Speed | 🚀 Native Speed |
| UI Updates | ❌ Requires App Store Release | ✅ Instant (Deploy to Web) | ✅ Instant (JSON change) |
| Logic Updates | ❌ Requires App Store Release | ✅ Instant (Deploy to Web) | ✅ Instant (Server API) |
| Offline | ✅ Full Support | ❌ Limited (Service Workers) | ✅ Cached JSON |
| Native APIs | ✅ Direct C# Access | ⚠️ JS Interop Bridge | ✅ Direct C# Access |
| Complexity | Low | Low | High (Requires Architecture) |
3. High-Level Design
The flow of data and logic follows a strict "Server-Authoritative" pattern.
- Layout Phase: App starts → Requests GET /api/feed/home.
- Server Logic: API determines context (e.g., "User has a ticket" AND "It is Game Day").
- Blueprint Generation: API constructs a JSON list of widgets, prioritizing the DigitalTicketWidget.
- Client Rendering: App iterates the JSON, finding the matching local .razor component for each widget in its Registry.
- Action Phase: User clicks button → App sends raw intent to Server (POST /api/action) → Server executes logic → Server returns outcome ("Navigate" or "Show Error").
4. Phase 1: The Shared Protocol
Both Client (MAUI) and Server (ASP.NET Core) reference this library. We use Polymorphism to allow a list of different widget types.
File: FanApp.Shared/Models/Widgets.cs
using System.Text.Json.Serialization;
// 1. THE BASE DISCRIMINATOR
// The 'widgetType' property tells .NET which class to instantiate.
[JsonPolymorphic(TypeDiscriminatorPropertyName = "widgetType")]
[JsonDerivedType(typeof(LiveMatchWidget), typeDiscriminator: "live-match")]
[JsonDerivedType(typeof(DigitalTicketWidget), typeDiscriminator: "digital-ticket")]
[JsonDerivedType(typeof(ActionWidget), typeDiscriminator: "action-button")]
[JsonDerivedType(typeof(WebFallbackWidget), typeDiscriminator: "web-fallback")]
public abstract class UiWidgetBase
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string BackgroundColor { get; set; } = "#FFFFFF";
}
// 2. LIVE SCORE WIDGET
public class LiveMatchWidget : UiWidgetBase
{
public string HomeTeam { get; set; }
public string AwayTeam { get; set; }
// Server formats this string (e.g., "2 - 1"). Client displays it blindly.
public string ScoreDisplay { get; set; }
}
// 3. TICKET WIDGET (Native Secure Rendering)
public class DigitalTicketWidget : UiWidgetBase
{
public string SeatInfo { get; set; }
public string QrPayload { get; set; } // Encrypted Token
public string GateLabel { get; set; }
}
// 4. ACTION WIDGET (Logic Proxy)
// Used for buttons. The client knows NOTHING about what this button does.
public class ActionWidget : UiWidgetBase
{
public string Label { get; set; }
public string ButtonStyle { get; set; } = "Primary";
public string ActionEndpoint { get; set; } // e.g., "api/tickets/transfer"
public object? Payload { get; set; } // Data to send back
}
// 5. WEB FALLBACK (The Safety Net)
public class WebFallbackWidget : UiWidgetBase
{
public string Url { get; set; }
public int HeightRequest { get; set; }
}
5. Phase 2: The Server (The Architect)
The API is responsible for two things: Composing the View and Executing the Logic.
A. Layout Controller (UI Definition)
File: FanApp.Api/Controllers/FeedController.cs
[HttpGet("home")]
public ActionResult<List<UiWidgetBase>> GetHomeFeed()
{
var feed = new List<UiWidgetBase>();
bool isGameDay = true;
if (isGameDay)
{
// LOGIC ON SERVER: User has a ticket, show it first.
feed.Add(new DigitalTicketWidget
{
SeatInfo = "Sec 105 | Row C | Seat 12",
QrPayload = "ENCRYPTED_TOKEN_123",
BackgroundColor = "#F0F0F0"
});
// LOGIC ON SERVER: Add a button to trigger a check.
feed.Add(new ActionWidget
{
Label = "Upgrade Seat",
ActionEndpoint = "api/tickets/upgrade-check",
Payload = new { ticketId = 12345 }
});
}
// EMERGENCY: Native "Shop" page is crashing on Android.
// FIX: Send WebFallback instead of NativeShopWidget.
feed.Add(new WebFallbackWidget
{
Url = "https://fanapp.com/shop-mobile",
HeightRequest = 500
});
return Ok(feed);
}
B. Logic Controller (Business Rules)
This is where we fix bugs without app updates.
File: FanApp.Api/Controllers/TicketController.cs
[HttpPost("upgrade-check")]
public ActionResult<UiResponse> CheckUpgrade([FromBody] JsonElement data)
{
// BUG FIX SCENARIO:
// Previously, allowed upgrades 1 hour before game.
// Changed to 2 hours instantly on server. App doesn't know.
if (DateTime.Now > GameStart.AddHours(-2))
{
return Ok(new UiResponse
{
ActionType = "ShowToast",
Message = "Upgrades closed 2 hours before kickoff."
});
}
// Success logic
return Ok(new UiResponse
{
ActionType = "Navigate",
TargetUrl = "fanapp://payment/upgrade"
});
}
6. Phase 3: The Client Engine (MAUI)
A. The Registry
Maps Data types to Native Blazor Components.
File: Services/WidgetRegistry.cs
public static class WidgetRegistry
{
public static readonly Dictionary<Type, Type> Map = new()
{
{ typeof(DigitalTicketWidget), typeof(Components.Widgets.TicketCard) },
{ typeof(ActionWidget), typeof(Components.Widgets.ServerActionButton) },
{ typeof(WebFallbackWidget), typeof(Components.Widgets.WebViewContainer) }
};
}
B. The Renderer
This is the engine of the application. It iterates through the list of widgets provided by the server and renders them dynamically using the built-in DynamicComponent. Note how we wrap each widget in a styled container to respect the layout hints (Background, ID) from the protocol.
What is DynamicComponent?
You do not need to create this file. DynamicComponent is a built-in feature of Blazor (namespace Microsoft.AspNetCore.Components). It allows you to render a component when you only know its Type at runtime, rather than at compile time.
File: Pages/Home.razor
@page "/"
@inject HttpClient Http
@if (widgets == null) { <Spinner /> }
else
{
<div class="page-container">
@foreach (var widget in widgets)
{
<!-- Wrapper div applies the generic layout properties from UiWidgetBase -->
<div class="widget-wrapper"
id="@widget.Id"
style="background-color: @widget.BackgroundColor; margin-bottom: 10px;">
<!-- The Magic: Renders the specific Razor component -->
<DynamicComponent Type="@GetNativeComponent(widget)"
Parameters="@GetParams(widget)" />
</div>
}
</div>
}
@code {
List<UiWidgetBase> widgets;
protected override async Task OnInitializedAsync()
{
widgets = await Http.GetFromJsonAsync<List<UiWidgetBase>>("api/feed/home");
}
Type GetNativeComponent(UiWidgetBase widget)
{
if (WidgetRegistry.Map.TryGetValue(widget.GetType(), out var type))
return type;
return typeof(EmptyComponent);
}
Dictionary<string, object> GetParams(UiWidgetBase widget)
{
return new Dictionary<string, object> { { "Model", widget } };
}
}
Under the Hood: How DynamicComponent is Defined
If you are curious about how DynamicComponent works (or need to write a custom version), it is a pure C# component that manually constructs the RenderTree. It does not use a .razor file.
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
public class DynamicComponent : IComponent
{
[Parameter]
public Type Type { get; set; }
[Parameter]
public IDictionary<string, object> Parameters { get; set; }
public void Attach(RenderHandle renderHandle)
{
// Manual Rendering Logic
renderHandle.Render(builder =>
{
if (Type != null)
{
builder.OpenComponent(0, Type);
if (Parameters != null)
{
builder.AddMultipleAttributes(1, Parameters);
}
builder.CloseComponent();
}
});
}
public Task SetParametersAsync(ParameterView parameters)
{
// Parameter setting logic...
return Task.CompletedTask;
}
}
C. Advanced: Recursive Containers (Grouping Widgets)
To build complex layouts (e.g., a "Match Card" that contains a Header, Score, and Highlights button), we use the Container Pattern. A widget can hold a list of other widgets.
1. The Model (Nested Children):
public class SectionWidget : UiWidgetBase
{
public string Title { get; set; }
// This widget contains a list of OTHER widgets!
public List<UiWidgetBase> Children { get; set; }
}
2. The Component (Recursive Loop):
The Razor component for the container simply repeats the rendering loop for its children. This allows for infinite nesting.
@* Components/Widgets/SectionContainer.razor *@
<div class="section-card shadow-sm">
<h2 class="section-header">@Model.Title</h2>
<div class="section-body">
@foreach (var child in Model.Children)
{
<!-- RECURSION: Render the child widgets inside this card -->
<DynamicComponent Type="@WidgetRegistry.Map[child.GetType()]"
Parameters="@(new Dictionary<string,object>{{ "Model", child }})" />
}
</div>
</div>
@code {
[Parameter] public SectionWidget Model { get; set; }
}
7. Phase 4: Component Implementation
The Server Action Button (Logic Proxy)
This button demonstrates the "Thin Client." It contains no if statements.
File: Components/Widgets/ServerActionButton.razor
<button class="btn @Model.ButtonStyle" @onclick="HandleClick">
@Model.Label
</button>
@code {
[Parameter] public ActionWidget Model { get; set; }
[Inject] public ServerActionService ActionService { get; set; }
private async Task HandleClick()
{
// The component blindly forwards the payload to the endpoint.
await ActionService.Execute(Model.ActionEndpoint, Model.Payload);
}
}
The Web Fallback (Safety Net)
This component ensures we can always ship a feature via the web if the native code isn't ready.
File: Components/Widgets/WebViewContainer.razor
<div class="web-wrapper" style="height: @(Model.HeightRequest)px">
<!-- Inject Auth Token so user is logged in automatically -->
<iframe src="@AuthenticatedUrl" frameborder="0" width="100%" height="100%"></iframe>
</div>
@code {
[Parameter] public WebFallbackWidget Model { get; set; }
[Inject] public AuthService Auth { get; set; }
string AuthenticatedUrl => $"{Model.Url}?access_token={Auth.GetToken()}";
}
8. The Thin Client Rules
To ensure we never need an App Store update for a logic bug, we follow three strict rules:
- Never Validate Locally: Do not check if an email is valid in C#. Send it to the server. If invalid, the server returns the error message string.
- Never Calculate Locally: Do not calculate Total = Price * Quantity in C#. The server should send the TotalDisplay string.
- Drive Navigation from Server: Buttons should not hardcode NavManager.NavigateTo("/checkout"). They should hit an API endpoint, which returns instructions like { Action: "Navigate", Target: "/checkout" }.
9. Operational Workflow: Updates vs. Releases
| Scenario | Action Required | Deployment Time |
|---|---|---|
| Content Update (Change "Game Day" to "Off-Season" layout) |
Update Server Logic / CMS. | ✅ Instant |
| Emergency Fix (Hide a crashing widget) |
Remove widget from Server JSON list. | ✅ Instant |
| Logic Bug (Ticket transfer fee calculation wrong) |
Update API C# logic. | ✅ Instant |
| New Feature (Web) (Add temporary Survey form) |
Add WebFallbackWidget to JSON. | ✅ Instant |
| New Feature (Native) (Add Biometric Entry Widget) |
1. Create .razor file 2. Compile Binary 3. Submit to App Store |
❌ 24-48 Hours |
10. Styling Architecture
Where does the CSS live?
In Server-Driven UI, the server sends Data and Semantics (e.g., "This button is Dangerous"), but the client holds the Actual CSS Definitions (e.g., ".btn-danger is red with 10px padding"). This hybrid approach ensures 60FPS performance and offline support.
1. Local Styles (The Source of Truth)
The CSS files live in the MAUI project, primarily in wwwroot/css/app.css or as Scoped CSS (e.g., TicketCard.razor.css). This ensures instant loading.
File: Components/Widgets/ServerActionButton.razor.css
/* Standard definitions live locally */
.btn {
padding: 12px 24px;
border-radius: 8px;
font-weight: bold;
}
.btn-primary { background-color: #007bff; color: white; }
.btn-danger { background-color: #dc3545; color: white; }
.btn-outline { border: 2px solid #007bff; color: #007bff; }
2. Server Tokens (The Driver)
The server doesn't send "red" or "10px". It sends semantic tokens via the JSON model.
JSON Response from API:
{
"widgetType": "action-button",
"label": "Delete Ticket",
"buttonStyle": "btn-danger" // <-- This is the token
}
3. The Binding (The Connection)
The Razor component simply maps the string from the server to the CSS class attribute.
File: ServerActionButton.razor
<!--
The server controls the style by sending "btn-danger" or "btn-primary".
The client controls what "btn-danger" actually looks like.
-->
<button class="btn @Model.ButtonStyle" @onclick="HandleClick">
@Model.Label
</button>
4. Dynamic Overrides (Optional)
If you absolutely need dynamic colors (e.g., Team Colors), send the Hex Code in the JSON and bind it to a style attribute.
<div class="card" style="background-color: @Model.BackgroundColor">
...
</div>
11. Web Platform Support (Blazor WASM)
Can this architecture run as a website?
Yes. Because Blazor Hybrid and Blazor WebAssembly share the same component model, 98% of this code is reusable for a web version of FanApp.
Implementation Strategy
If you want to deploy FanApp to fanapp.com as well as the App Store:
- Share the Components: The Rendering Engine (Home.razor), Registry, and Protocol should live in a Razor Class Library (RCL) referenced by both the MAUI project and the Blazor WebAssembly project.
- Handle Native Features (Dependency Injection):
- The DigitalTicketWidget uses native Apple Wallet APIs.
- In MAUI: Register IWalletService as NativeWalletService (calls iOS APIs).
- In WASM: Register IWalletService as WebWalletService (shows a "Download App" modal or uses a JavaScript library for PKPass).
- Web Fallback Handling: On the web, the WebFallbackWidget (iframe) still works, or you can implement a logic check to render the component directly if you are already in a browser context.