
Dotnet Ui
- 182 installs
- 228 repo stars
- Updated August 3, 2026
- novotnyllc/dotnet-artisan
Build Blazor or Razor UI layers with dotnet-artisan patterns—layouts, components, forms, and server or WASM interop—for .NET web applications sharing backend contracts.
About
dotnet-artisan dotnet-ui skill helps Claude implement Blazor or Razor frontends—components, routing, forms, and service integration—matching Artisan .NET conventions for cohesive full-stack SaaS web applications.
- Blazor and Razor components
- Layout and routing patterns
- Form binding and validation UI
- Component state and services
- Artisan UI project structure
Dotnet Ui by the numbers
- 182 all-time installs (skills.sh)
- +10 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #70 of 153 .NET & C# skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/novotnyllc/dotnet-artisan --skill dotnet-uiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 182 |
|---|---|
| repo stars | ★ 228 |
| Last updated | August 3, 2026 |
| Repository | novotnyllc/dotnet-artisan ↗ |
What it does
Build Blazor or Razor UI layers with dotnet-artisan patterns—layouts, components, forms, and server or WASM interop—for .NET web applications sharing backend contracts.
Files
dotnet-ui
Overview
.NET UI development across Blazor, MAUI, Uno Platform, WPF, WinUI 3, and WinForms. This skill covers framework selection, component architecture, XAML patterns, MVVM, platform-specific deployment, accessibility, and localization. Each framework area has a dedicated companion file with deep guidance.
Routing Table
| Topic | Keywords | Description | Companion File |
|---|---|---|---|
| Blazor patterns | hosting model, render mode, routing, streaming, prerender | Hosting models, render modes, routing, streaming, prerendering, AOT-safe patterns | references/blazor-patterns.md |
| Blazor components | lifecycle, state, JS interop, EditForm, QuickGrid | Lifecycle methods, state management, JS interop, EditForm, QuickGrid | references/blazor-components.md |
| Blazor auth | AuthorizeView, Identity UI, OIDC flows | Login/logout flows, AuthorizeView, Identity UI, OIDC, role and policy auth | references/blazor-auth.md |
| Blazor testing | bUnit, rendering, events, JS mocking | bUnit component rendering, events, cascading params, JS interop mocking | references/blazor-testing.md |
| MAUI development | project structure, XAML, MVVM, platform services | Project structure, XAML/MVVM patterns, Shell navigation, platform services | references/maui-development.md |
| MAUI AOT | iOS/Catalyst, Native AOT, trimming | iOS/Catalyst Native AOT pipeline, size/startup gains, library compatibility | references/maui-aot.md |
| MAUI testing | Appium, XHarness, platform validation | Appium 2.x device automation, XHarness, platform validation | references/maui-testing.md |
| Uno Platform | Extensions, MVUX, Toolkit, Hot Reload | Extensions ecosystem, MVUX pattern, Toolkit controls, Hot Reload | references/uno-platform.md |
| Uno targets | WASM, iOS, Android, macOS, Windows, Linux | Per-target guidance for WASM, iOS, Android, macOS, Windows, Linux | references/uno-targets.md |
| Uno MCP | tool detection, search-then-fetch, init | MCP tool detection, search-then-fetch workflow, init rules, fallback | references/uno-mcp.md |
| Uno testing | Playwright WASM, platform patterns | Playwright for WASM, platform-specific test patterns, runtime heads | references/uno-testing.md |
| WPF modern | Host builder, MVVM Toolkit, Fluent theme | Host builder, MVVM Toolkit, Fluent theme, performance, modern C# | references/wpf-modern.md |
| WPF migration | WPF/WinForms to .NET 8+, UWP to WinUI | WPF/WinForms to .NET 8+, UWP to WinUI, Upgrade Assistant | references/wpf-migration.md |
| WinUI | Windows App SDK, XAML, MSIX/unpackaged | Windows App SDK, x:Bind, x:Load, MSIX/unpackaged, UWP migration | references/winui.md |
| WinForms | high-DPI, dark mode, DI, modernization | High-DPI scaling, dark mode, DI patterns, modernization | references/winforms-basics.md |
| Accessibility | SemanticProperties, ARIA, AutomationPeer | SemanticProperties, ARIA attributes, AutomationPeer, per-platform testing | references/accessibility.md |
| Localization | .resx, IStringLocalizer, pluralization, RTL | .resx resources, IStringLocalizer, source generators, pluralization, RTL | references/localization.md |
| WinUI controls/styling | CommandBar, GridView, adaptive triggers, Mica, system brushes, icons | WinUI control selection, adaptive layout, theming, materials, typography | references/winui-controls-styling.md |
| UI chooser | framework selection decision tree | Decision tree across Blazor, MAUI, Uno, WinUI, WPF, WinForms | references/ui-chooser.md |
| Platform bindings | Java.Interop, ObjCRuntime, Android AAR, iOS XCFramework, Slim Binding | Custom native SDK bindings for Android and Apple platforms | references/platform-bindings.md |
Scope
- Blazor (Server, WASM, Hybrid, Auto) hosting models and components
- MAUI mobile/desktop development and Native AOT
- Uno Platform cross-platform development and MCP integration
- WPF on .NET 8+ and migration from .NET Framework
- WinUI 3 / Windows App SDK
- WinForms modernization (high-DPI, dark mode, DI)
- Accessibility across all UI frameworks
- Localization (.resx, IStringLocalizer, pluralization, RTL)
- UI framework selection decision tree
Out of scope
- Server-side auth middleware and API security configuration -- see [skill:dotnet-api]
- Non-UI testing strategy (unit, integration, E2E architecture) -- see [skill:dotnet-testing]
- Cross-framework UI test patterns (page objects, selectors) -- see [skill:dotnet-testing]
- Playwright browser automation (non-framework-specific) -- see [skill:dotnet-testing]
- Backend API patterns and architecture -- see [skill:dotnet-api]
- Native AOT compilation (non-MAUI) -- see [skill:dotnet-tooling]
- Console UI (Terminal.Gui, Spectre.Console) -- see [skill:dotnet-tooling]
interface:
display_name: "dotnet-ui"
short_description: "Blazor, MAUI, Uno, WPF, and WinUI guidance"
default_prompt: "Use $dotnet-advisor to route this UI request, then load $dotnet-ui for framework-specific patterns and tradeoffs."
policy:
allow_implicit_invocation: true
Accessibility
Cross-platform accessibility patterns for .NET UI frameworks: semantic markup, keyboard navigation, focus management, color contrast, and screen reader integration. In-depth coverage for Blazor (HTML ARIA), MAUI (SemanticProperties), and WinUI (AutomationProperties / UI Automation). Brief guidance with cross-references for WPF, Uno Platform, and TUI frameworks.
Cross-Platform Principles
These principles apply across all .NET UI frameworks. Framework-specific implementations follow in subsequent sections.
Semantic Markup
Provide meaningful names and descriptions for all interactive and informational elements. Screen readers rely on semantic metadata -- not visual appearance -- to convey UI structure.
- Every interactive control must have an accessible name (text label, ARIA label, or automation property)
- Images and icons must have text alternatives describing their purpose
- Decorative elements should be hidden from the accessibility tree
- Group related controls logically so screen readers announce them in context
Keyboard Navigation
All functionality must be operable via keyboard alone. Users who cannot use a mouse, pointer, or touch depend entirely on keyboard interaction.
- Maintain a logical tab order that follows the visual reading flow
- Provide visible focus indicators on all interactive elements
- Support standard keyboard patterns: Tab/Shift+Tab for navigation, Enter/Space for activation, Escape to dismiss, arrow keys within composite controls
- Avoid keyboard traps -- users must be able to navigate away from every control
Focus Management
Programmatic focus management ensures screen readers announce context changes correctly.
- Move focus to newly revealed content (dialogs, expanded panels, inline notifications)
- Return focus to the triggering element when dismissing overlays
- Avoid stealing focus unexpectedly during background updates
- Set initial focus on the primary action when a page or dialog loads
Color Contrast
Ensure text and interactive elements meet WCAG contrast ratios.
| Element Type | Minimum Ratio (WCAG AA) | Enhanced Ratio (WCAG AAA) |
|---|---|---|
| Normal text (< 18pt) | 4.5:1 | 7:1 |
| Large text (>= 18pt or 14pt bold) | 3:1 | 4.5:1 |
| UI components and graphical objects | 3:1 | 3:1 |
- Do not rely on color alone to convey information (use icons, patterns, or text labels as supplements)
- Support high-contrast themes and system color overrides
- Test with color blindness simulation tools
Blazor Accessibility (In-Depth)
Blazor renders HTML, so standard web accessibility patterns apply. Use native HTML semantics and ARIA attributes to build accessible Blazor apps.
Semantic HTML and ARIA
@* Use semantic HTML elements for structure *@
<nav aria-label="Main navigation">
<ul>
<li><a href="/products">Products</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
<main>
<h1>Product Catalog</h1>
@* Image with alt text *@
<img src="hero.png" alt="Product showcase displaying three featured items" />
@* Decorative image hidden from accessibility tree *@
<img src="divider.svg" alt="" role="presentation" />
@* Button with accessible name from content *@
<button @onclick="AddToCart">Add to Cart</button>
@* Icon button requires aria-label *@
<button @onclick="ToggleFavorite" aria-label="Add to favorites">
<span class="icon-heart" aria-hidden="true"></span>
</button>
</main>Keyboard Event Handling
<div role="listbox"
tabindex="0"
aria-label="Product list"
aria-activedescendant="@_activeId"
@onkeydown="HandleKeyDown"
@onkeydown:preventDefault>
@foreach (var product in Products)
{
<div id="@($"product-{product.Id}")"
role="option"
aria-selected="@(product.Id == SelectedId)"
@onclick="() => Select(product)">
@product.Name
</div>
}
</div>
@code {
private string _activeId = "";
private void HandleKeyDown(KeyboardEventArgs e)
{
switch (e.Key)
{
case "ArrowDown":
MoveSelection(1);
break;
case "ArrowUp":
MoveSelection(-1);
break;
case "Enter":
case " ":
ConfirmSelection();
break;
}
}
}Live Regions
Announce dynamic content changes to screen readers without moving focus:
@* Polite: announced after current speech finishes *@
<div aria-live="polite" aria-atomic="true">
@if (_statusMessage is not null)
{
<p>@_statusMessage</p>
}
</div>
@* Assertive: interrupts current speech (use sparingly) *@
<div aria-live="assertive" role="alert">
@if (_errorMessage is not null)
{
<p>@_errorMessage</p>
}
</div>Form Accessibility
<EditForm Model="@_model" OnValidSubmit="HandleSubmit">
<DataAnnotationsValidator />
<div>
<label for="product-name">Product name</label>
<InputText id="product-name"
@bind-Value="_model.Name"
aria-describedby="name-error"
aria-invalid="@(_nameInvalid ? "true" : null)" />
<ValidationMessage For="() => _model.Name" id="name-error" />
</div>
<div>
<label for="quantity">Quantity</label>
<InputNumber id="quantity"
@bind-Value="_model.Quantity"
aria-describedby="quantity-help"
min="1" max="100" />
<span id="quantity-help">Enter a value between 1 and 100</span>
</div>
<button type="submit">Submit Order</button>
</EditForm>For Blazor hosting models and render mode configuration, see references/blazor-patterns.md. For component lifecycle and EditForm patterns, see references/blazor-components.md.
MAUI Accessibility (In-Depth)
MAUI provides the SemanticProperties attached properties as the recommended accessibility API. These map to native platform accessibility APIs (VoiceOver on iOS/macOS, TalkBack on Android, Narrator on Windows).
SemanticProperties
<!-- Description: primary screen reader announcement -->
<Image Source="product.png"
SemanticProperties.Description="Product photo showing a blue widget" />
<!-- Hint: additional context about an action -->
<Button Text="Add to Cart"
SemanticProperties.Hint="Adds the current product to your shopping cart" />
<!-- HeadingLevel: enables heading-based navigation -->
<Label Text="Order Summary"
SemanticProperties.HeadingLevel="Level1" />
<Label Text="Items"
SemanticProperties.HeadingLevel="Level2" />Key APIs:
SemanticProperties.Description-- short text the screen reader announces (equivalent toaccessibilityLabelon iOS,contentDescriptionon Android)SemanticProperties.Hint-- additional purpose context (equivalent toaccessibilityHinton iOS)SemanticProperties.HeadingLevel-- marks headings (Level1 through Level9); Android and iOS only support a single heading level, Windows supports all 9
Platform warning: Do not set SemanticProperties.Description on a Label -- it overrides the Text property for screen readers, creating a mismatch between visual and spoken text. Do not set SemanticProperties.Description on Entry or Editor on Android -- use Placeholder or SemanticProperties.Hint instead, because Description conflicts with TalkBack actions.
Legacy AutomationProperties
AutomationProperties are the older Xamarin.Forms API, superseded by SemanticProperties in MAUI. Use SemanticProperties for new code.
| Legacy API | Replacement |
|---|---|
AutomationProperties.Name | SemanticProperties.Description |
AutomationProperties.HelpText | SemanticProperties.Hint |
AutomationProperties.LabeledBy | Bind SemanticProperties.Description to the label's Text |
AutomationProperties.IsInAccessibleTree and AutomationProperties.ExcludedWithChildren remain useful for controlling accessibility tree inclusion.
Programmatic Focus and Announcements
// Move screen reader focus to a specific element
myLabel.SetSemanticFocus();
// Announce text to the screen reader without moving focus
SemanticScreenReader.Default.Announce("Item added to cart successfully.");Accessible Custom Controls
When building custom controls, ensure accessibility metadata is set:
public class RatingControl : ContentView
{
private int _rating;
public int Rating
{
get => _rating;
set
{
_rating = value;
SemanticProperties.SetDescription(this,
$"Rating: {value} out of 5 stars");
SemanticScreenReader.Default.Announce(
$"Rating changed to {value} stars");
}
}
}For MAUI project structure, MVVM patterns, and platform services, see references/maui-development.md.
WinUI Accessibility (In-Depth)
WinUI 3 / Windows App SDK builds on the Microsoft UI Automation framework. Built-in controls include automation support by default. Custom controls need automation peers.
AutomationProperties
<!-- Name: primary accessible name for screen readers -->
<Image Source="ms-appx:///Assets/product.png"
AutomationProperties.Name="Product photo showing a blue widget" />
<!-- HelpText: supplementary description -->
<Button Content="Add to Cart"
AutomationProperties.HelpText="Adds the current product to your shopping cart" />
<!-- LabeledBy: associates a label with a control -->
<TextBlock x:Name="QuantityLabel" Text="Quantity:" />
<NumberBox AutomationProperties.LabeledBy="{x:Bind QuantityLabel}"
Value="{x:Bind ViewModel.Quantity, Mode=TwoWay}" />
<!-- Hide decorative elements from accessibility tree -->
<Image Source="ms-appx:///Assets/divider.png"
AutomationProperties.AccessibilityView="Raw" />Custom Automation Peers
For custom controls, implement an AutomationPeer to expose the control to UI Automation clients:
// Custom control
public sealed class StarRating : Control
{
public int Value
{
get => (int)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value), typeof(int),
typeof(StarRating), new PropertyMetadata(0, OnValueChanged));
private static void OnValueChanged(DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
if (FrameworkElementAutomationPeer
.FromElement((StarRating)d) is StarRatingAutomationPeer peer)
{
peer.RaiseValueChanged((int)e.OldValue, (int)e.NewValue);
}
}
protected override AutomationPeer OnCreateAutomationPeer()
=> new StarRatingAutomationPeer(this);
}
// Automation peer (using Microsoft.UI.Xaml.Automation.Provider)
public sealed class StarRatingAutomationPeer
: FrameworkElementAutomationPeer, IRangeValueProvider
{
private StarRating Owner => (StarRating)base.Owner;
public StarRatingAutomationPeer(StarRating owner) : base(owner) { }
protected override string GetClassNameCore() => nameof(StarRating);
protected override string GetNameCore()
=> $"Rating: {Owner.Value} out of 5 stars";
protected override AutomationControlType GetAutomationControlTypeCore()
=> AutomationControlType.Slider;
// IRangeValueProvider
public double Value => Owner.Value;
public double Minimum => 0;
public double Maximum => 5;
public double SmallChange => 1;
public double LargeChange => 1;
public bool IsReadOnly => false;
public void SetValue(double value)
=> Owner.Value = (int)Math.Clamp(value, Minimum, Maximum);
public void RaiseValueChanged(int oldValue, int newValue)
{
RaisePropertyChangedEvent(
RangeValuePatternIdentifiers.ValueProperty,
(double)oldValue, (double)newValue);
}
}Keyboard Accessibility in WinUI
WinUI XAML controls provide built-in keyboard support. Ensure custom controls follow the same patterns:
<!-- TabIndex controls navigation order -->
<TextBox Header="First name" TabIndex="1" />
<TextBox Header="Last name" TabIndex="2" />
<Button Content="Submit" TabIndex="3" />
<!-- AccessKey provides keyboard shortcuts (Alt + key) -->
<Button Content="Save" AccessKey="S" />
<Button Content="Delete" AccessKey="D" />For WinUI project setup, XAML patterns, and Windows integration, see references/winui.md.
WPF Accessibility (Brief)
WPF on .NET 8+ uses the same UI Automation framework as WinUI. The APIs are nearly identical with namespace differences.
AutomationProperties.Name,AutomationProperties.HelpText,AutomationProperties.LabeledBywork the same as in WinUI- Custom controls override
OnCreateAutomationPeer()and return aFrameworkElementAutomationPeersubclass - WPF Fluent theme (.NET 9+) includes high-contrast support automatically
- Use
AutomationProperties.LiveSettingfor live region announcements
<!-- WPF accessibility follows the same pattern as WinUI -->
<Image Source="product.png"
AutomationProperties.Name="Product photo" />
<TextBlock x:Name="StatusLabel"
AutomationProperties.LiveSetting="Polite"
Text="{Binding StatusText}" />For WPF development patterns on .NET 8+, see references/wpf-modern.md.
Uno Platform Accessibility (Brief)
Uno Platform follows UWP/WinUI AutomationProperties patterns since its API surface is WinUI-compatible.
AutomationProperties.Name,AutomationProperties.HelpText,AutomationProperties.LabeledBywork cross-platform- Custom
AutomationPeerimplementations follow the WinUI pattern - On WebAssembly, Uno maps
AutomationPropertiesto HTML ARIA attributes automatically - Platform-specific behavior may vary -- test on each target (Windows, iOS, Android, WASM)
For Uno Platform development patterns, see references/uno-platform.md. For per-target deployment and testing, see references/uno-targets.md.
TUI Accessibility (Brief)
Terminal UI frameworks have inherent accessibility limitations. Screen reader support depends on the terminal emulator and operating system.
Terminal.Gui (v2):
- Screen readers can read terminal text content via the terminal emulator's accessibility support
- No programmatic accessibility API equivalent to ARIA or AutomationProperties
- Logical tab order follows the
TabIndexproperty on views - High contrast is managed by terminal color themes, not the app
Spectre.Console:
- Output-only library -- screen readers read terminal text buffer directly
- Use plain text fallbacks for complex visual elements (tables, trees) when accessibility is critical
AnsiConsole.Profile.Capabilitiescan detect terminal features but not screen reader presence
Honest constraint: TUI apps cannot programmatically control screen reader behavior. Terminal emulators provide varying levels of accessibility support. For applications where accessibility is a hard requirement, consider a GUI framework (Blazor, MAUI, WinUI) instead.
For Terminal.Gui patterns, see [skill:dotnet-tooling]. For Spectre.Console patterns, see [skill:dotnet-tooling].
Accessibility Testing Tools
Per-Platform Testing
| Platform | Primary Tool | Secondary Tools |
|---|---|---|
| Windows | Accessibility Insights for Windows | Narrator (Win+Ctrl+Enter), Inspect.exe (Windows SDK) |
| Web (Blazor) | axe-core / axe DevTools | Lighthouse (Chrome), WAVE, NVDA, VoiceOver (macOS) |
| Android | Accessibility Scanner | TalkBack, Android Studio Layout Inspector |
| iOS / macOS | Accessibility Inspector (Xcode) | VoiceOver (built-in), XCUITest accessibility assertions |
Automated Testing Integration
// Blazor: integrate axe-core with Playwright for automated accessibility testing
// Requires: Deque.AxeCore.Playwright NuGet package
// Install: dotnet add package Deque.AxeCore.Playwright
var axeResults = await new Deque.AxeCore.Playwright.AxeBuilder(page)
.AnalyzeAsync();
// Check for violations
Assert.Empty(axeResults.Violations);
// WinUI/WPF: use Accessibility Insights for Windows CLI in CI pipelines
// Requires: AccessibilityInsights.CLI (available via Microsoft Store or direct download)Manual Testing Checklist
1. Keyboard-only navigation -- tab through entire app without mouse; verify all functionality is reachable 2. Screen reader walkthrough -- enable Narrator/VoiceOver/TalkBack and navigate the full workflow 3. High contrast -- enable system high-contrast theme and verify all content remains visible 4. Zoom/scaling -- increase text size to 200% and verify layout does not break or clip content 5. Color contrast -- verify all text and interactive elements meet WCAG AA ratios (4.5:1 for text, 3:1 for large text and UI components)
WCAG Reference
This skill references the Web Content Accessibility Guidelines (WCAG) as the global accessibility standard. WCAG 2.1 is the current baseline; WCAG 2.2 adds additional criteria for mobile and cognitive accessibility.
Four principles (POUR): 1. Perceivable -- information must be presentable in ways all users can perceive 2. Operable -- UI components must be operable by all users 3. Understandable -- information and UI operation must be understandable 4. Robust -- content must be robust enough to work with assistive technologies
Conformance levels: A (minimum), AA (recommended target for most apps), AAA (enhanced). Most legal requirements and industry standards target WCAG 2.1 Level AA.
Note: This skill provides technical implementation guidance. It does not constitute legal advice regarding accessibility compliance requirements, which vary by jurisdiction and application type.
Agent Gotchas
1. Do not set `SemanticProperties.Description` on MAUI `Label` controls. It overrides the Text property for screen readers, causing a mismatch between visual and spoken content. Labels are already accessible via their Text property. 2. Do not set `SemanticProperties.Description` on MAUI `Entry`/`Editor` on Android. Use Placeholder or SemanticProperties.Hint instead -- Description conflicts with TalkBack actions on these controls. 3. Do not use `AutomationProperties.Name` or `AutomationProperties.HelpText` for new MAUI code. Use SemanticProperties instead (the MAUI-native API). AutomationProperties.IsInAccessibleTree and ExcludedWithChildren remain valid for controlling accessibility tree inclusion. 4. Do not omit `aria-label` on icon-only Blazor buttons. Buttons without visible text content are invisible to screen readers unless aria-label or aria-labelledby is set. 5. Do not use `aria-live="assertive"` for routine status updates. Assertive interrupts the screen reader immediately. Use aria-live="polite" for non-critical updates; reserve assertive for errors and time-critical alerts. 6. Do not assume TUI apps are accessible by default. Terminal screen reader support varies dramatically by emulator and OS. Always provide alternative output formats for critical accessibility scenarios. 7. Do not hardcode colors without verifying contrast ratios. Use tools (Accessibility Insights, Lighthouse) to verify WCAG AA compliance. System high-contrast themes must also be tested. 8. Do not forget `AccessKey` on frequently used WinUI/WPF buttons. Access keys (Alt+key shortcuts) are essential for keyboard-dependent users and are trivial to add.
Prerequisites
- .NET 8.0+ (baseline for all frameworks)
- Framework-specific SDKs: MAUI workload, Windows App SDK (WinUI), Blazor project template
- Testing tools: Accessibility Insights (Windows), axe-core (web), Xcode Accessibility Inspector (macOS/iOS)
- Screen readers for manual testing: Narrator (Windows), VoiceOver (macOS/iOS), TalkBack (Android), NVDA (Windows, free)
References
Blazor Authentication
Authentication and authorization across all Blazor hosting models. Covers AuthorizeView, CascadingAuthenticationState, Identity UI scaffolding, role/policy-based authorization, per-hosting-model auth flow differences (cookie vs token), and external identity providers.
Auth Flow per Hosting Model
Authentication patterns differ significantly across Blazor hosting models:
| Concern | InteractiveServer | InteractiveWebAssembly | InteractiveAuto | Static SSR | Hybrid |
|---|---|---|---|---|---|
| Auth mechanism | Cookie-based (server-side) | Token-based (JWT/OIDC) | Cookie (Server phase), Token (WASM phase) | Cookie-based (standard ASP.NET Core) | Platform-native or cookie |
| User state access | Direct HttpContext access | AuthenticationStateProvider | Varies by phase | HttpContext | Platform auth APIs |
| Token storage | Not needed (cookie) | localStorage or sessionStorage | Transition from cookie to token | Not needed (cookie) | Secure storage (Keychain, etc.) |
| Refresh handling | Circuit reconnection | Token refresh via interceptor | Automatic | Standard cookie renewal | Platform-specific |
InteractiveServer Auth
Server-side Blazor uses cookie authentication. The user authenticates via a standard ASP.NET Core login flow, and the cookie is sent with the initial HTTP request that establishes the SignalR circuit.
// Program.cs
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/Account/Login";
options.AccessDeniedPath = "/Account/AccessDenied";
});
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();Gotcha: HttpContext is available during the initial HTTP request but is null inside interactive components after the SignalR circuit is established. Do not access HttpContext in interactive component lifecycle methods. Use AuthenticationStateProvider instead.
InteractiveWebAssembly Auth
WASM runs in the browser. Cookie auth works for same-origin APIs (and Backend-for-Frontend / BFF patterns), but token-based auth (OIDC/JWT) is the standard approach for cross-origin APIs and delegated access scenarios:
// Client Program.cs (WASM)
builder.Services.AddOidcAuthentication(options =>
{
options.ProviderOptions.Authority = "https://login.example.com";
options.ProviderOptions.ClientId = "blazor-wasm-client";
options.ProviderOptions.ResponseType = "code";
options.ProviderOptions.DefaultScopes.Add("api");
});// Attach tokens to API calls using BaseAddressAuthorizationMessageHandler
// (auto-attaches tokens for requests to the app's base address)
builder.Services.AddHttpClient("API", client =>
client.BaseAddress = new Uri("https://api.example.com"))
.AddHttpMessageHandler(sp =>
sp.GetRequiredService<AuthorizationMessageHandler>()
.ConfigureHandler(
authorizedUrls: ["https://api.example.com"],
scopes: ["api"]));
builder.Services.AddScoped(sp =>
sp.GetRequiredService<IHttpClientFactory>().CreateClient("API"));InteractiveAuto Auth
Auto mode starts as InteractiveServer (cookie auth), then transitions to WASM (token auth). Handle both:
// Server Program.cs
builder.Services.AddAuthentication()
.AddCookie()
.AddJwtBearer(); // For WASM API calls after transition
builder.Services.AddCascadingAuthenticationState();Hybrid (MAUI) Auth
// Register platform-specific auth
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, MauiAuthStateProvider>();
// Custom provider using secure storage
public class MauiAuthStateProvider : AuthenticationStateProvider
{
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var token = await SecureStorage.Default.GetAsync("auth_token");
if (string.IsNullOrEmpty(token))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
var claims = ParseClaimsFromJwt(token);
var identity = new ClaimsIdentity(claims, "jwt");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
}AuthorizeView
AuthorizeView conditionally renders content based on the user's authentication and authorization state.
Basic Usage
<AuthorizeView>
<Authorized>
<p>Welcome, @context.User.Identity?.Name!</p>
<a href="/Account/Logout">Log out</a>
</Authorized>
<NotAuthorized>
<a href="/Account/Login">Log in</a>
</NotAuthorized>
<Authorizing>
<p>Checking authentication...</p>
</Authorizing>
</AuthorizeView>Role-Based
<AuthorizeView Roles="Admin,Manager">
<Authorized>
<AdminDashboard />
</Authorized>
<NotAuthorized>
<p>You do not have access to the admin dashboard.</p>
</NotAuthorized>
</AuthorizeView>Policy-Based
<AuthorizeView Policy="CanEditProducts">
<Authorized>
<button @onclick="EditProduct">Edit</button>
</Authorized>
</AuthorizeView>// Register policy in Program.cs
builder.Services.AddAuthorizationBuilder()
.AddPolicy("CanEditProducts", policy =>
policy.RequireClaim("permission", "products.edit"));CascadingAuthenticationState
CascadingAuthenticationState provides the current AuthenticationState as a cascading parameter to all descendant components.
Setup
// Program.cs -- register cascading auth state
builder.Services.AddCascadingAuthenticationState();This replaces wrapping the entire app in <CascadingAuthenticationState> (the older pattern). The service-based registration (.NET 8+) is preferred.
Consuming Auth State in Components
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthState { get; set; }
private string? userName;
protected override async Task OnInitializedAsync()
{
if (AuthState is not null)
{
var state = await AuthState;
userName = state.User.Identity?.Name;
}
}
}Accessing Claims
var state = await AuthState;
var user = state.User;
// Check authentication
if (user.Identity?.IsAuthenticated == true)
{
var email = user.FindFirst(ClaimTypes.Email)?.Value;
var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value);
var isAdmin = user.IsInRole("Admin");
}Identity UI Scaffolding
ASP.NET Core Identity provides a complete authentication system with registration, login, email confirmation, password reset, and two-factor authentication.
Adding Identity to a Blazor Web App
# Add Identity scaffolding
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.AspNetCore.Identity.UI// Program.cs
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
options.Password.RequireNonAlphanumeric = true;
options.SignIn.RequireConfirmedAccount = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();Scaffolding Identity Pages
# Scaffold individual Identity pages for customization
dotnet aspnet-codegenerator identity -dc ApplicationDbContext --files "Account.Login;Account.Register;Account.Logout"Custom Identity UI with Blazor Components
For a fully Blazor-native auth experience, create Blazor components that call Identity APIs:
@page "/Account/Login"
@inject SignInManager<ApplicationUser> SignInManager
@inject NavigationManager Navigation
<EditForm Model="loginModel" OnValidSubmit="HandleLogin" FormName="login" Enhance>
<DataAnnotationsValidator />
<ValidationSummary />
<div>
<InputText @bind-Value="loginModel.Email" placeholder="Email" />
</div>
<div>
<InputText @bind-Value="loginModel.Password" type="password" placeholder="Password" />
</div>
<div>
<InputCheckbox @bind-Value="loginModel.RememberMe" /> Remember me
</div>
<button type="submit">Log in</button>
</EditForm>
@if (!string.IsNullOrEmpty(errorMessage))
{
<p class="text-danger">@errorMessage</p>
}
@code {
[SupplyParameterFromForm]
private LoginModel loginModel { get; set; } = new();
private string? errorMessage;
private async Task HandleLogin()
{
var result = await SignInManager.PasswordSignInAsync(
loginModel.Email, loginModel.Password,
loginModel.RememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
Navigation.NavigateTo("/", forceLoad: true);
}
else if (result.RequiresTwoFactor)
{
Navigation.NavigateTo("/Account/LoginWith2fa");
}
else if (result.IsLockedOut)
{
errorMessage = "Account is locked. Try again later.";
}
else
{
errorMessage = "Invalid login attempt.";
}
}
}Gotcha: SignInManager uses HttpContext to set cookies. In Interactive render modes, HttpContext is not available after the circuit is established. Login/logout pages must use Static SSR (no @rendermode) so they have access to HttpContext for cookie operations.
Role and Policy-Based Authorization
Page-Level Authorization
@page "/admin"
@attribute [Authorize(Roles = "Admin")]
<h1>Admin Panel</h1>@page "/products/manage"
@attribute [Authorize(Policy = "ProductManager")]
<h1>Manage Products</h1>Defining Policies
builder.Services.AddAuthorizationBuilder()
.AddPolicy("ProductManager", policy =>
policy.RequireRole("Admin", "ProductManager"))
.AddPolicy("CanDeleteOrders", policy =>
policy.RequireClaim("permission", "orders.delete")
.RequireAuthenticatedUser())
.AddPolicy("MinimumAge", policy =>
policy.AddRequirements(new MinimumAgeRequirement(18)));Custom Authorization Handler
public sealed class MinimumAgeRequirement(int minimumAge) : IAuthorizationRequirement
{
public int MinimumAge { get; } = minimumAge;
}
public sealed class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumAgeRequirement requirement)
{
var dateOfBirthClaim = context.User.FindFirst("date_of_birth");
if (dateOfBirthClaim is not null
&& DateOnly.TryParse(dateOfBirthClaim.Value, out var dob))
{
var age = DateOnly.FromDateTime(DateTime.UtcNow).Year - dob.Year;
if (age >= requirement.MinimumAge)
{
context.Succeed(requirement);
}
}
return Task.CompletedTask;
}
}
// Register
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();Procedural Authorization in Components
@inject IAuthorizationService AuthorizationService
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthState { get; set; }
private bool canEdit;
protected override async Task OnInitializedAsync()
{
if (AuthState is not null)
{
var state = await AuthState;
var result = await AuthorizationService.AuthorizeAsync(
state.User, "CanEditProducts");
canEdit = result.Succeeded;
}
}
}External Identity Providers
Adding External Providers
builder.Services.AddAuthentication()
.AddMicrosoftAccount(options =>
{
options.ClientId = builder.Configuration["Auth:Microsoft:ClientId"]!;
options.ClientSecret = builder.Configuration["Auth:Microsoft:ClientSecret"]!;
})
.AddGoogle(options =>
{
options.ClientId = builder.Configuration["Auth:Google:ClientId"]!;
options.ClientSecret = builder.Configuration["Auth:Google:ClientSecret"]!;
});External Login Flow per Hosting Model
| Hosting Model | Flow | Notes |
|---|---|---|
| InteractiveServer / Static SSR | Standard OAuth redirect (server-side) | Cookie stored after callback |
| InteractiveWebAssembly | OIDC with PKCE (client-side) | Token stored in browser |
| Hybrid (MAUI) | WebAuthenticator or MSAL | Platform-specific secure storage |
For WASM, configure the OIDC provider in the client project:
// Client Program.cs
builder.Services.AddOidcAuthentication(options =>
{
options.ProviderOptions.Authority = "https://login.microsoftonline.com/{tenant}";
options.ProviderOptions.ClientId = "{client-id}";
options.ProviderOptions.ResponseType = "code";
});For MAUI Hybrid:
var result = await WebAuthenticator.Default.AuthenticateAsync(
new Uri("https://login.example.com/authorize"),
new Uri("myapp://callback"));
var token = result.AccessToken;Agent Gotchas
1. Do not access `HttpContext` in interactive components. HttpContext is only available during the initial HTTP request. After the SignalR circuit is established (InteractiveServer) or the WASM runtime loads, it is null. Use AuthenticationStateProvider or CascadingAuthenticationState instead. 2. Do not rely on cookies for cross-origin or delegated API access in WASM. Use OIDC/JWT with AuthorizationMessageHandler for cross-origin APIs. Same-origin and Backend-for-Frontend (BFF) cookie auth remains valid for WASM apps. 3. Do not render login/logout pages in Interactive mode. SignInManager requires HttpContext to set/clear cookies. Login and logout pages must use Static SSR render mode. 4. Do not store tokens in `localStorage` without considering XSS. If the app is vulnerable to XSS, tokens in localStorage can be stolen. Use sessionStorage (cleared on tab close) or the OIDC library's built-in storage mechanisms with PKCE. 5. Do not forget `AddCascadingAuthenticationState()`. Without it, [CascadingParameter] Task<AuthenticationState> is always null in components, silently breaking auth checks. 6. Do not use `AddIdentity` and `AddDefaultIdentity` together. AddDefaultIdentity includes UI scaffolding; AddIdentity does not. Choose one based on whether you want the default Identity UI pages.
Prerequisites
- .NET 8.0+ (Blazor Web App with render modes,
AddCascadingAuthenticationStateservice registration) Microsoft.AspNetCore.Identity.EntityFrameworkCorefor Identity with EF CoreMicrosoft.AspNetCore.Identity.UIfor default Identity UI scaffoldingMicrosoft.AspNetCore.Authentication.MicrosoftAccount/.Googlefor external providersMicrosoft.Authentication.WebAssembly.Msalfor WASM with Microsoft Identity (Azure AD/Entra)
References
Blazor Components
Blazor component architecture: lifecycle methods, state management (cascading values, DI, browser storage), JavaScript interop (AOT-safe), EditForm validation, and QuickGrid. Covers per-render-mode behavior differences where relevant.
Component Lifecycle
Lifecycle Methods
@code {
// 1. Called when parameters are set/updated
public override async Task SetParametersAsync(ParameterView parameters)
{
// Access raw parameters before they are applied
await base.SetParametersAsync(parameters);
}
// 2. Called after parameters are assigned (sync)
protected override void OnInitialized()
{
// One-time initialization (runs once per component instance)
}
// 3. Called after parameters are assigned (async)
protected override async Task OnInitializedAsync()
{
// Async initialization (data fetching, service calls)
products = await ProductService.GetProductsAsync();
}
// 4. Called every time parameters change
protected override void OnParametersSet()
{
// React to parameter changes
}
// 5. Called after each render
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
// JS interop safe here -- DOM is available
}
}
// 6. Async version of OnAfterRender
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JSRuntime.InvokeVoidAsync("initializeChart", chartElement);
}
}
// 7. Cleanup
public void Dispose()
{
// Unsubscribe from events, dispose resources
}
// 8. Async cleanup
public async ValueTask DisposeAsync()
{
// Async cleanup (dispose JS object references)
if (module is not null)
{
await module.DisposeAsync();
}
}
}Lifecycle Behavior per Render Mode
| Lifecycle Event | Static SSR | InteractiveServer | InteractiveWebAssembly | InteractiveAuto | Hybrid |
|---|---|---|---|---|---|
OnInitialized(Async) | Runs on server | Runs on server | Runs in browser | Server on first load, browser after WASM cached | Runs in-process |
OnAfterRender(Async) | Never called | Runs on server after SignalR confirms render | Runs in browser after DOM update | Server-side then browser-side (matches active runtime) | Runs after WebView render |
Dispose(Async) | Called after response | Called when circuit ends | Called on component removal | Called when circuit ends (Server phase) or on removal (WASM phase) | Called on component removal |
Gotcha: In Static SSR, OnAfterRender never executes because there is no persistent connection. Do not place critical logic in OnAfterRender for Static SSR pages.
State Management
Cascading Values
Cascading values flow data down the component tree without explicit parameter passing.
<!-- Parent: provide a cascading value -->
<CascadingValue Value="@theme" Name="AppTheme">
<Router AppAssembly="typeof(App).Assembly">
<!-- All descendants can receive AppTheme -->
</Router>
</CascadingValue>
@code {
private ThemeSettings theme = new() { IsDarkMode = false, AccentColor = "#0078d4" };
}<!-- Child: consume the cascading value -->
@code {
[CascadingParameter(Name = "AppTheme")]
public ThemeSettings? Theme { get; set; }
}Fixed cascading values (.NET 8+): For values that never change after initial render, use IsFixed="true" to avoid re-render overhead:
<CascadingValue Value="@config" IsFixed="true">
<ChildComponent />
</CascadingValue>Dependency Injection
// Register services in Program.cs
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddSingleton<AppState>();
// Inject in components
@inject IProductService ProductService
@inject AppState StateDI lifetime behavior per render mode:
| Lifetime | InteractiveServer | InteractiveWebAssembly | InteractiveAuto | Hybrid |
|---|---|---|---|---|
| Singleton | Shared across all circuits on the server | One per browser tab | Server-shared during Server phase; per-tab after WASM switch | One per app instance |
| Scoped | One per circuit (acts like per-user) | One per browser tab (same as Singleton) | Per-circuit (Server phase), per-tab (WASM phase) -- state does not transfer between phases | One per app instance (same as Singleton) |
| Transient | New instance each injection | New instance each injection | New instance each injection | New instance each injection |
Gotcha: In Blazor Server, Scoped services live for the entire circuit duration (not per-request like in MVC). A circuit persists until the user navigates away or the connection drops. Long-lived scoped services may accumulate state -- use OwningComponentBase<T> for component-scoped DI.
Browser Storage
// ProtectedBrowserStorage -- encrypted, per-user storage
// Available in InteractiveServer only (not WASM -- server encrypts/decrypts)
@inject ProtectedSessionStorage SessionStorage
@inject ProtectedLocalStorage LocalStorage
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Session storage (cleared when tab closes)
await SessionStorage.SetAsync("cart", cartItems);
var result = await SessionStorage.GetAsync<List<CartItem>>("cart");
if (result.Success) { cartItems = result.Value!; }
// Local storage (persists across sessions)
await LocalStorage.SetAsync("preferences", userPrefs);
}
}For InteractiveWebAssembly, use JS interop to access browser storage directly:
// WASM: Direct browser storage via JS interop
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "key",
JsonSerializer.Serialize(value, AppJsonContext.Default.UserPrefs));
var json = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", "key");
if (json is not null)
{
value = JsonSerializer.Deserialize(json, AppJsonContext.Default.UserPrefs);
}Gotcha: ProtectedBrowserStorage is not available during prerendering. Always access it in OnAfterRenderAsync(firstRender: true), never in OnInitializedAsync.
JavaScript Interop
Calling JavaScript from .NET
@inject IJSRuntime JSRuntime
// Invoke a global JS function
await JSRuntime.InvokeVoidAsync("console.log", "Hello from Blazor");
// Invoke and get a return value
var width = await JSRuntime.InvokeAsync<int>("getWindowWidth");
// With timeout (important for Server to avoid hanging circuits)
var result = await JSRuntime.InvokeAsync<string>(
"expensiveOperation",
TimeSpan.FromSeconds(10),
inputData);JavaScript Module Imports (AOT-Safe)
// Import a JS module -- trim-safe, no reflection
private IJSObjectReference? module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
module = await JSRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/interop.js");
await module.InvokeVoidAsync("initialize", elementRef);
}
}
// Always dispose module references
public async ValueTask DisposeAsync()
{
if (module is not null)
{
await module.DisposeAsync();
}
}// wwwroot/js/interop.js
export function initialize(element) {
// Set up the element
}
export function getValue(element) {
return element.value;
}Calling .NET from JavaScript
// Instance method callback
private DotNetObjectReference<MyComponent>? dotNetRef;
protected override void OnInitialized()
{
dotNetRef = DotNetObjectReference.Create(this);
}
[JSInvokable]
public void OnJsEvent(string data)
{
message = data;
StateHasChanged();
}
public void Dispose()
{
dotNetRef?.Dispose();
}// Call .NET from JS
export function registerCallback(dotNetRef) {
document.addEventListener('custom-event', (e) => {
dotNetRef.invokeMethodAsync('OnJsEvent', e.detail);
});
}JS Interop per Render Mode
| Concern | InteractiveServer | InteractiveWebAssembly | InteractiveAuto | Hybrid |
|---|---|---|---|---|
| JS call timing | After SignalR confirms render | After WASM runtime loads | SignalR initially, then direct after WASM switch | After WebView loads |
OnAfterRender available | Yes | Yes | Yes | Yes |
| IJSRuntime sync calls | Not supported (async only) | IJSInProcessRuntime available | Async-only during Server phase; IJSInProcessRuntime after WASM switch | IJSInProcessRuntime available |
| Module imports | Via SignalR (latency) | Direct (fast) | SignalR (Server phase), direct (WASM phase) | Direct (fast) |
Gotcha: In InteractiveServer, all JS interop calls travel over SignalR, adding network latency. Minimize round trips by batching operations into a single JS function call.
EditForm Validation
Basic EditForm with Data Annotations
<EditForm Model="product" OnValidSubmit="HandleSubmit" FormName="product-form">
<DataAnnotationsValidator />
<ValidationSummary />
<div>
<label for="name">Name:</label>
<InputText id="name" @bind-Value="product.Name" />
<ValidationMessage For="() => product.Name" />
</div>
<div>
<label for="price">Price:</label>
<InputNumber id="price" @bind-Value="product.Price" />
<ValidationMessage For="() => product.Price" />
</div>
<div>
<label for="category">Category:</label>
<InputSelect id="category" @bind-Value="product.Category">
<option value="">Select...</option>
<option value="Electronics">Electronics</option>
<option value="Clothing">Clothing</option>
</InputSelect>
<ValidationMessage For="() => product.Category" />
</div>
<button type="submit">Save</button>
</EditForm>
@code {
private ProductModel product = new();
private async Task HandleSubmit()
{
await ProductService.CreateAsync(product);
Navigation.NavigateTo("/products");
}
}Model with Validation Attributes
public sealed class ProductModel
{
[Required(ErrorMessage = "Product name is required")]
[StringLength(200, MinimumLength = 1)]
public string Name { get; set; } = "";
[Range(0.01, 1_000_000, ErrorMessage = "Price must be between {1} and {2}")]
public decimal Price { get; set; }
[Required(ErrorMessage = "Category is required")]
public string Category { get; set; } = "";
}EditForm with Enhanced Form Handling (.NET 8+)
Static SSR forms require FormName and use [SupplyParameterFromForm]:
@page "/products/create"
<EditForm Model="product" OnValidSubmit="HandleSubmit" FormName="create-product" Enhance>
<DataAnnotationsValidator />
<!-- form fields -->
<button type="submit">Create</button>
</EditForm>
@code {
[SupplyParameterFromForm]
private ProductModel product { get; set; } = new();
private async Task HandleSubmit()
{
await ProductService.CreateAsync(product);
Navigation.NavigateTo("/products");
}
}The Enhance attribute enables enhanced form handling -- the form submits via fetch and patches the DOM without a full page reload.
Gotcha: FormName must be unique across all forms on the page. Duplicate FormName values cause ambiguous form submission errors.
QuickGrid
QuickGrid is a high-performance grid component built into Blazor (.NET 8+). It supports sorting, filtering, pagination, and virtualization.
Basic QuickGrid
@using Microsoft.AspNetCore.Components.QuickGrid
<QuickGrid Items="products">
<PropertyColumn Property="p => p.Name" Sortable="true" />
<PropertyColumn Property="p => p.Price" Format="C2" Sortable="true" />
<PropertyColumn Property="p => p.Category" Sortable="true" />
<TemplateColumn Title="Actions">
<button @onclick="() => Edit(context)">Edit</button>
</TemplateColumn>
</QuickGrid>
@code {
private IQueryable<Product> products = Enumerable.Empty<Product>().AsQueryable();
protected override async Task OnInitializedAsync()
{
var list = await ProductService.GetAllAsync();
products = list.AsQueryable();
}
private void Edit(Product product) => Navigation.NavigateTo($"/products/{product.Id}/edit");
}QuickGrid with Pagination
<QuickGrid Items="products" Pagination="pagination">
<PropertyColumn Property="p => p.Name" Sortable="true" />
<PropertyColumn Property="p => p.Price" Format="C2" />
</QuickGrid>
<Paginator State="pagination" />
@code {
private PaginationState pagination = new() { ItemsPerPage = 20 };
private IQueryable<Product> products = default!;
}QuickGrid with Virtualization
For large datasets, virtualization renders only visible rows:
<QuickGrid Items="products" Virtualize="true" ItemSize="50">
<PropertyColumn Property="p => p.Name" />
<PropertyColumn Property="p => p.Price" Format="C2" />
</QuickGrid><!-- net11-preview -->
QuickGrid OnRowClick (.NET 11 Preview)
.NET 11 adds OnRowClick to QuickGrid for row-level click handling without template columns:
<QuickGrid Items="products" OnRowClick="HandleRowClick">
<PropertyColumn Property="p => p.Name" />
<PropertyColumn Property="p => p.Price" Format="C2" />
</QuickGrid>
@code {
private void HandleRowClick(GridRowClickEventArgs<Product> args)
{
Navigation.NavigateTo($"/products/{args.Item.Id}");
}
}Fallback (net10.0): Use a TemplateColumn with a click handler or wrap each row in a clickable element.
Source: ASP.NET Core .NET 11 Preview - QuickGrid enhancements
<!-- net11-preview -->
.NET 11 Preview Features
EnvironmentBoundary Component
EnvironmentBoundary conditionally renders content based on the hosting environment (Development, Staging, Production):
<EnvironmentBoundary Include="Development">
<p>Debug panel -- only visible in Development</p>
<DebugToolbar />
</EnvironmentBoundary>
<EnvironmentBoundary Exclude="Production">
<p>Testing controls -- hidden in Production</p>
</EnvironmentBoundary>Fallback (net10.0): Inject IWebHostEnvironment and use conditional rendering in @code.
Source: ASP.NET Core .NET 11 Preview - EnvironmentBoundary
Label and DisplayName Support
.NET 11 adds [DisplayName] support for input components, automatically generating <label> elements:
<EditForm Model="model" FormName="contact">
<!-- Automatically renders <label> from [DisplayName] -->
<InputText @bind-Value="model.FullName" />
<InputText @bind-Value="model.EmailAddress" />
</EditForm>
@code {
private ContactModel model = new();
}
// Model
public sealed class ContactModel
{
[DisplayName("Full Name")]
[Required]
public string FullName { get; set; } = "";
[DisplayName("Email Address")]
[EmailAddress]
public string EmailAddress { get; set; } = "";
}Fallback (net10.0): Add explicit <label for="..."> elements manually.
Source: ASP.NET Core .NET 11 Preview - Label/DisplayName
IHostedService in WebAssembly
.NET 11 allows IHostedService implementations to run in Blazor WebAssembly, enabling background tasks in the browser:
// Register in WASM Program.cs
builder.Services.AddHostedService<DataSyncService>();
public sealed class DataSyncService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await SyncDataFromServer();
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}Fallback (net10.0): Use a Timer in a component or inject a singleton service that starts background work on first use.
Source: ASP.NET Core .NET 11 Preview - IHostedService in WASM
<!-- net11-preview -->
SignalR ConfigureConnection
.NET 11 adds ConfigureConnection to the Blazor Server circuit hub, allowing customization of the SignalR connection (e.g., adding custom headers, configuring reconnection):
// Program.cs
app.MapBlazorHub(options =>
{
options.ConfigureConnection = connection =>
{
connection.Metadata["tenant"] = "default";
};
});Fallback (net10.0): Use IHubFilter or middleware to inspect/modify connections at the hub level.
Source: ASP.NET Core .NET 11 Preview - SignalR ConfigureConnection
Agent Gotchas
1. Do not call JS interop in `OnInitializedAsync`. The DOM is not available yet. Use OnAfterRenderAsync(firstRender: true) for JS calls that need DOM elements. 2. Do not forget `StateHasChanged()` after external state changes. When state changes from a non-Blazor context (timer, event handler, JS callback), call StateHasChanged() or InvokeAsync(StateHasChanged) to trigger re-render. 3. Do not use `ProtectedBrowserStorage` during prerendering. It throws because no interactive circuit exists yet. Access it only in OnAfterRenderAsync. 4. Do not forget `FormName` on Static SSR forms. Without it, form submissions in Static SSR mode are not routed to the correct handler. 5. Do not dispose `DotNetObjectReference` before JS is done with it. Premature disposal causes JSException when JavaScript tries to invoke the callback. Dispose in Dispose() or DisposeAsync(). 6. Do not assume Scoped services are per-request in Blazor Server. Scoped services live for the entire circuit. Use OwningComponentBase<T> when you need component-scoped service lifetimes.
Prerequisites
- .NET 8.0+ (QuickGrid, enhanced form handling, cascading values with
IsFixed) Microsoft.AspNetCore.Components.QuickGridpackage for QuickGrid- .NET 11 preview for EnvironmentBoundary, Label/DisplayName, QuickGrid OnRowClick, IHostedService in WASM
Knowledge Sources
Blazor component patterns in this skill are grounded in guidance from:
- Damian Edwards -- Razor and Blazor component design patterns, render mode architecture, and performance best practices. Principal architect on the ASP.NET team.
These sources inform the patterns and rationale presented above. This skill does not claim to represent or speak for any individual.
References
Blazor Patterns
Blazor hosting models, render modes, project setup, routing, enhanced navigation, streaming rendering, and AOT-safe patterns. Covers all five hosting models (InteractiveServer, InteractiveWebAssembly, InteractiveAuto, Static SSR, Hybrid) with trade-off analysis for each.
Hosting Models & Render Modes
Blazor Web App (.NET 8+) is the default project template, replacing the separate Blazor Server and Blazor WebAssembly templates. Render modes can be set globally, per-page, or per-component.
Render Mode Overview
| Render Mode | Attribute | Interactivity | Connection | Best For |
|---|---|---|---|---|
| Static SSR | (none / default) | None -- server renders HTML, no interactivity | HTTP request only | Content pages, SEO, forms with minimal interactivity |
| InteractiveServer | @rendermode InteractiveServer | Full | SignalR circuit | Low-latency interactivity, full server access, small user base |
| InteractiveWebAssembly | @rendermode InteractiveWebAssembly | Full (after download) | None (runs in browser) | Offline-capable, large user base, reduced server load |
| InteractiveAuto | @rendermode InteractiveAuto | Full | SignalR initially, then WASM | Best of both -- immediate interactivity, eventual client-side |
| Blazor Hybrid | BlazorWebView in MAUI/WPF/WinForms | Full (native) | None (runs in-process) | Desktop/mobile apps with web UI, native API access |
Per-Mode Trade-offs
| Concern | Static SSR | InteractiveServer | InteractiveWebAssembly | InteractiveAuto | Hybrid |
|---|---|---|---|---|---|
| First load | Fast | Fast | Slow (WASM download) | Fast (Server first) | Instant (local) |
| Server resources | Minimal | Per-user circuit | None after download | Circuit then none | None |
| Offline support | No | No | Yes | Partial | Yes |
| Full .NET API access | Yes (server) | Yes (server) | Limited (browser sandbox) | Varies by phase | Yes (native) |
| Scalability | High | Limited by circuits | High | High (after WASM) | N/A (local) |
| SEO | Yes | Prerender | Prerender | Prerender | N/A |
Setting Render Modes
Global (App.razor):
<!-- Sets default render mode for all pages -->
<Routes @rendermode="InteractiveServer" />Per-page:
@page "/dashboard"
@rendermode InteractiveServer
<h1>Dashboard</h1>Per-component:
<Counter @rendermode="InteractiveWebAssembly" />Gotcha: Without an explicit render mode boundary, a child component cannot request a more interactive render mode than its parent. However, interactive islands are supported: you can place an @rendermode attribute on a component embedded in a Static SSR page to create a render mode boundary, enabling interactive children under otherwise static content.
Project Setup
Blazor Web App (Default Template)
# Creates a Blazor Web App with InteractiveServer render mode
dotnet new blazor -n MyApp
# With specific interactivity options
dotnet new blazor -n MyApp --interactivity Auto # InteractiveAuto
dotnet new blazor -n MyApp --interactivity WebAssembly # InteractiveWebAssembly
dotnet new blazor -n MyApp --interactivity Server # InteractiveServer (default)
dotnet new blazor -n MyApp --interactivity None # Static SSR onlyBlazor Web App Project Structure
MyApp/
MyApp/ # Server project
Program.cs # Host builder, services, middleware
Components/
App.razor # Root component (sets global render mode)
Routes.razor # Router component
Layout/
MainLayout.razor # Main layout
Pages/
Home.razor # Static SSR by default
Counter.razor # Can set per-page render mode
MyApp.Client/ # Client project (only if WASM or Auto)
Pages/
Counter.razor # Components that run in browser
Program.cs # WASM entry pointWhen using InteractiveAuto or InteractiveWebAssembly, components that must run in the browser go in the .Client project. Components in the server project run on the server only.
Blazor Hybrid Setup (MAUI)
<!-- .csproj for MAUI Blazor Hybrid -->
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<OutputType>Exe</OutputType>
<UseMaui>true</UseMaui>
</PropertyGroup>
</Project>// MainPage.xaml.cs hosts BlazorWebView
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
}
}<!-- MainPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:b="clr-namespace:Microsoft.AspNetCore.Components.WebView.Maui;assembly=Microsoft.AspNetCore.Components.WebView.Maui">
<b:BlazorWebView HostPage="wwwroot/index.html">
<b:BlazorWebView.RootComponents>
<b:RootComponent Selector="#app" ComponentType="{x:Type local:Routes}" />
</b:BlazorWebView.RootComponents>
</b:BlazorWebView>
</ContentPage>Routing
Basic Routing
@page "/products"
@page "/products/{Category}"
<h1>Products</h1>
@if (!string.IsNullOrEmpty(Category))
{
<p>Category: @Category</p>
}
@code {
[Parameter]
public string? Category { get; set; }
}Route Constraints
@page "/products/{Id:int}"
@page "/orders/{Date:datetime}"
@page "/search/{Query:minlength(3)}"
@code {
[Parameter] public int Id { get; set; }
[Parameter] public DateTime Date { get; set; }
[Parameter] public string Query { get; set; } = "";
}Query String Parameters
@page "/search"
@code {
[SupplyParameterFromQuery]
public string? Term { get; set; }
[SupplyParameterFromQuery(Name = "page")]
public int CurrentPage { get; set; } = 1;
}NavigationManager
@inject NavigationManager Navigation
// Programmatic navigation
Navigation.NavigateTo("/products/electronics");
// With query string
Navigation.NavigateTo("/search?term=keyboard&page=2");
// Force full page reload (bypasses enhanced navigation)
Navigation.NavigateTo("/external-page", forceLoad: true);Enhanced Navigation (.NET 8+)
Enhanced navigation intercepts link clicks and form submissions to update only the changed DOM content, preserving page state and avoiding full page reloads. This applies to Static SSR and prerendered pages.
How It Works
1. User clicks a link within the Blazor app 2. Blazor intercepts the navigation 3. A fetch request loads the new page content 4. Blazor patches the DOM with only the differences 5. Scroll position and focus state are preserved
Opting Out
<!-- Disable enhanced navigation for a specific link -->
<a href="/legacy-page" data-enhance-nav="false">Legacy Page</a>
<!-- Disable enhanced form handling for a specific form -->
<form method="post" data-enhance="false">
...
</form>Gotcha: Enhanced navigation may interfere with third-party JavaScript libraries that expect full page loads. Use data-enhance-nav="false" on links that navigate to pages with JS that initializes on DOMContentLoaded.
Streaming Rendering (.NET 8+)
Streaming rendering sends initial HTML immediately (with placeholder content), then streams updates as async operations complete. Useful for pages with slow data sources.
@page "/dashboard"
@attribute [StreamRendering]
<h1>Dashboard</h1>
@if (orders is null)
{
<p>Loading orders...</p>
}
else
{
<table>
@foreach (var order in orders)
{
<tr><td>@order.Id</td><td>@order.Total</td></tr>
}
</table>
}
@code {
private List<OrderDto>? orders;
protected override async Task OnInitializedAsync()
{
// Initial HTML sent immediately with "Loading orders..."
// Updated HTML streamed when this completes
orders = await OrderService.GetRecentOrdersAsync();
}
}Behavior per render mode:
- Static SSR: Streaming rendering sends the initial response, then patches the DOM via chunked transfer encoding. The page is not interactive.
- InteractiveServer/WebAssembly/Auto: Streaming rendering is less impactful because components re-render automatically after async operations. The
[StreamRendering]attribute primarily benefits the prerender phase.
AOT-Safe Patterns
When targeting Blazor WebAssembly with Native AOT (ahead-of-time compilation) or IL trimming, avoid patterns that rely on runtime reflection.
Source-Generator-First Serialization
// CORRECT: Source-generated JSON serialization (AOT-compatible)
[JsonSerializable(typeof(ProductDto))]
[JsonSerializable(typeof(List<ProductDto>))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
public partial class AppJsonContext : JsonSerializerContext { }
// Register in Program.cs
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});
// Usage in HttpClient calls
var products = await Http.GetFromJsonAsync<List<ProductDto>>(
"/api/products",
AppJsonContext.Default.ListProductDto);// WRONG: Reflection-based serialization (fails under AOT/trimming)
var products = await Http.GetFromJsonAsync<List<ProductDto>>("/api/products");Trim-Safe JS Interop
// CORRECT: Use IJSRuntime with explicit method names (no dynamic dispatch)
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "key", "value");
var value = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "key");
// CORRECT: Use IJSObjectReference for module imports (.NET 8+)
var module = await JSRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/chart.js");
await module.InvokeVoidAsync("initChart", elementRef, data);
await module.DisposeAsync();// WRONG: Dynamic dispatch via reflection (trimmed away)
// var method = typeof(JSRuntime).GetMethod("InvokeAsync");
// method.MakeGenericMethod(returnType).Invoke(...)Linker Configuration
<!-- Preserve types used dynamically in components -->
<ItemGroup>
<TrimmerRootAssembly Include="MyApp.Client" />
</ItemGroup>For types that must be preserved from trimming:
// Mark types that are accessed via reflection
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public class DynamicFormModel
{
// Properties discovered at runtime for form generation
public string Name { get; set; } = "";
public int Age { get; set; }
}Anti-Patterns to Avoid
1. Reflection-based DI -- Do not use Activator.CreateInstance or Type.GetType to resolve services. Use the built-in DI container with explicit registrations. 2. Dynamic type loading -- Do not use Assembly.Load or Assembly.GetTypes() at runtime. Register all types at startup. 3. Runtime code generation -- Do not use System.Reflection.Emit or System.Linq.Expressions.Expression.Compile(). Use source generators instead. 4. Untyped JSON deserialization -- Do not use JsonSerializer.Deserialize<T>(json) without a JsonSerializerContext. Always provide a source-generated context.
Prerendering
Prerendering generates HTML on the server before the interactive runtime loads. This improves perceived performance and SEO.
Prerender with Interactive Modes
<!-- Component prerenders on server, then becomes interactive -->
<Counter @rendermode="InteractiveServer" />By default, interactive components prerender. To disable:
@rendermode @(new InteractiveServerRenderMode(prerender: false))Persisting State Across Prerender
State computed during prerendering is lost when the component reinitializes interactively. Use PersistentComponentState to preserve it:
@inject PersistentComponentState ApplicationState
@implements IDisposable
@code {
private List<ProductDto>? products;
private PersistingComponentStateSubscription _subscription;
protected override async Task OnInitializedAsync()
{
_subscription = ApplicationState.RegisterOnPersisting(PersistState);
if (!ApplicationState.TryTakeFromJson<List<ProductDto>>(
"products", out var restored))
{
products = await ProductService.GetProductsAsync();
}
else
{
products = restored;
}
}
private Task PersistState()
{
ApplicationState.PersistAsJson("products", products);
return Task.CompletedTask;
}
public void Dispose() => _subscription.Dispose();
}.NET 10 Stable Features
These features are available when net10.0 TFM is detected. They are stable and require no preview opt-in.
WebAssembly Preloading
.NET 10 adds blazor.web.js preloading of WebAssembly assemblies during the Server phase of InteractiveAuto. When the user first loads a page, the WASM runtime and app assemblies download in the background while the Server circuit handles interactivity. Subsequent navigations switch to WASM faster because assemblies are already cached.
<!-- No code changes needed -- preloading is automatic in .NET 10 -->
<!-- Verify in browser DevTools Network tab: assemblies download during Server phase -->Enhanced Form Validation
.NET 10 extends EditForm validation with improved error message formatting and support for IValidatableObject in Static SSR forms. Validation messages render correctly with enhanced form handling (Enhance attribute) without requiring a full page reload.
// IValidatableObject works in Static SSR enhanced forms in .NET 10
public sealed class OrderModel : IValidatableObject
{
[Required]
public string ProductId { get; set; } = "";
[Range(1, 100)]
public int Quantity { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
if (ProductId == "DISCONTINUED" && Quantity > 0)
{
yield return new ValidationResult(
"Cannot order discontinued products",
[nameof(ProductId), nameof(Quantity)]);
}
}
}Blazor Diagnostics Middleware
.NET 10 adds MapBlazorDiagnostics middleware for inspecting Blazor circuit and component state in development:
// Program.cs -- available in .NET 10
if (app.Environment.IsDevelopment())
{
app.MapBlazorDiagnostics(); // Exposes /_blazor/diagnostics endpoint
}The diagnostics endpoint shows active circuits, component tree, render mode assignments, and timing data. Use it to debug render mode boundaries and component lifecycle issues during development.
Agent Gotchas
1. Do not default to InteractiveServer for every page. Static SSR is the default and most efficient render mode. Only add interactivity where user interaction requires it. 2. Do not put WASM-targeted components in the server project. Components that must run in the browser (InteractiveWebAssembly or InteractiveAuto) belong in the .Client project. 3. Do not forget `PersistentComponentState` when prerendering. Without it, data fetched during prerender is discarded and re-fetched when the component becomes interactive, causing a visible flicker. 4. Do not use reflection-based serialization in WASM. Always use JsonSerializerContext with source-generated serializers for AOT compatibility and trimming safety. 5. Do not force-load navigation unless leaving the Blazor app. NavigateTo("/page", forceLoad: true) bypasses enhanced navigation and causes a full page reload. 6. Do not nest interactive render modes incorrectly. A child component cannot request a more interactive mode than its parent. Plan render mode boundaries from the layout down.
Prerequisites
- .NET 8.0+ (Blazor Web App template, render modes, enhanced navigation, streaming rendering)
- .NET 10.0 for stable enhanced features (WebAssembly preloading, enhanced form validation, diagnostics middleware)
- MAUI workload for Blazor Hybrid (
dotnet workload install maui)
References
Blazor Testing
bUnit testing for Blazor components. Covers component rendering and markup assertions, event handling, cascading parameters and cascading values, JavaScript interop mocking, and async component lifecycle testing. bUnit provides an in-memory Blazor renderer that executes components without a browser.
Version assumptions: .NET 8.0+ baseline, bUnit 1.x (stable). Examples use the latest bUnit APIs. bUnit supports both Blazor Server and Blazor WebAssembly components.
Package Setup
<PackageReference Include="bunit" Version="1.*" />
<!-- bUnit depends on xunit internally; ensure compatible xUnit version -->bUnit test classes inherit from TestContext (or use it via composition):
using Bunit;
using Xunit;
// Inheritance approach (less boilerplate)
public class CounterTests : TestContext
{
[Fact]
public void Counter_InitialRender_ShowsZero()
{
var cut = RenderComponent<Counter>();
cut.Find("[data-testid='count']").MarkupMatches("<span data-testid=\"count\">0</span>");
}
}
// Composition approach (more flexibility)
public class CounterCompositionTests : IDisposable
{
private readonly TestContext _ctx = new();
[Fact]
public void Counter_InitialRender_ShowsZero()
{
var cut = _ctx.RenderComponent<Counter>();
Assert.Equal("0", cut.Find("[data-testid='count']").TextContent);
}
public void Dispose() => _ctx.Dispose();
}Component Rendering
Basic Rendering and Markup Assertions
public class AlertTests : TestContext
{
[Fact]
public void Alert_WithMessage_RendersCorrectMarkup()
{
var cut = RenderComponent<Alert>(parameters => parameters
.Add(p => p.Message, "Order saved successfully")
.Add(p => p.Severity, AlertSeverity.Success));
// Assert on text content
Assert.Contains("Order saved successfully", cut.Markup);
// Assert on specific elements
var alert = cut.Find("[data-testid='alert']");
Assert.Contains("success", alert.ClassList);
}
[Fact]
public void Alert_Dismissed_RendersNothing()
{
var cut = RenderComponent<Alert>(parameters => parameters
.Add(p => p.Message, "Info")
.Add(p => p.IsDismissed, true));
Assert.Empty(cut.Markup.Trim());
}
}Rendering with Child Content
[Fact]
public void Card_WithChildContent_RendersChildren()
{
var cut = RenderComponent<Card>(parameters => parameters
.AddChildContent("<p>Card body content</p>"));
cut.Find("p").MarkupMatches("<p>Card body content</p>");
}
[Fact]
public void Card_WithRenderFragment_RendersTemplate()
{
var cut = RenderComponent<Card>(parameters => parameters
.Add(p => p.Header, builder =>
{
builder.OpenElement(0, "h2");
builder.AddContent(1, "Card Title");
builder.CloseElement();
})
.AddChildContent("<p>Body</p>"));
cut.Find("h2").MarkupMatches("<h2>Card Title</h2>");
}Rendering with Dependency Injection
Register services before rendering components that depend on them:
public class OrderListTests : TestContext
{
[Fact]
public async Task OrderList_OnLoad_DisplaysOrders()
{
// Register mock service
var mockService = Substitute.For<IOrderService>();
mockService.GetOrdersAsync().Returns(
[
new OrderDto { Id = 1, CustomerName = "Alice", Total = 99.99m },
new OrderDto { Id = 2, CustomerName = "Bob", Total = 149.50m }
]);
Services.AddSingleton(mockService);
// Render component -- DI resolves IOrderService automatically
var cut = RenderComponent<OrderList>();
// Wait for async data loading
cut.WaitForState(() => cut.FindAll("[data-testid='order-row']").Count == 2);
var rows = cut.FindAll("[data-testid='order-row']");
Assert.Equal(2, rows.Count);
Assert.Contains("Alice", rows[0].TextContent);
}
}Event Handling
Click Events
[Fact]
public void Counter_ClickIncrement_IncreasesCount()
{
var cut = RenderComponent<Counter>();
cut.Find("[data-testid='increment-btn']").Click();
Assert.Equal("1", cut.Find("[data-testid='count']").TextContent);
}
[Fact]
public void Counter_MultipleClicks_AccumulatesCount()
{
var cut = RenderComponent<Counter>();
var button = cut.Find("[data-testid='increment-btn']");
button.Click();
button.Click();
button.Click();
Assert.Equal("3", cut.Find("[data-testid='count']").TextContent);
}Form Input Events
[Fact]
public void SearchBox_TypeText_UpdatesResults()
{
Services.AddSingleton(Substitute.For<ISearchService>());
var cut = RenderComponent<SearchBox>();
var input = cut.Find("[data-testid='search-input']");
input.Input("wireless keyboard");
Assert.Equal("wireless keyboard", cut.Instance.SearchTerm);
}
[Fact]
public async Task LoginForm_SubmitValid_CallsAuthService()
{
var authService = Substitute.For<IAuthService>();
authService.LoginAsync(Arg.Any<string>(), Arg.Any<string>())
.Returns(new AuthResult { Success = true });
Services.AddSingleton(authService);
var cut = RenderComponent<LoginForm>();
cut.Find("[data-testid='email']").Change("user@example.com");
cut.Find("[data-testid='password']").Change("P@ssw0rd!");
cut.Find("[data-testid='login-form']").Submit();
// Wait for async submission
cut.WaitForState(() => cut.Instance.IsAuthenticated);
await authService.Received(1).LoginAsync("user@example.com", "P@ssw0rd!");
}EventCallback Parameters
[Fact]
public void DeleteButton_Click_InvokesOnDeleteCallback()
{
var deletedId = 0;
var cut = RenderComponent<DeleteButton>(parameters => parameters
.Add(p => p.ItemId, 42)
.Add(p => p.OnDelete, EventCallback.Factory.Create<int>(
this, id => deletedId = id)));
cut.Find("[data-testid='delete-btn']").Click();
Assert.Equal(42, deletedId);
}Cascading Parameters
CascadingValue Setup
[Fact]
public void ThemedButton_WithDarkTheme_AppliesDarkClass()
{
var theme = new AppTheme { Mode = ThemeMode.Dark, PrimaryColor = "#1a1a2e" };
var cut = RenderComponent<ThemedButton>(parameters => parameters
.Add(p => p.Label, "Save")
.AddCascadingValue(theme));
var button = cut.Find("button");
Assert.Contains("dark-theme", button.ClassList);
}
[Fact]
public void UserDisplay_WithCascadedAuthState_ShowsUserName()
{
var authState = new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(ClaimTypes.Name, "Alice"),
new Claim(ClaimTypes.Role, "Admin")
], "TestAuth")));
var cut = RenderComponent<UserDisplay>(parameters => parameters
.AddCascadingValue(Task.FromResult(authState)));
Assert.Contains("Alice", cut.Find("[data-testid='user-name']").TextContent);
}Named Cascading Values
[Fact]
public void LayoutComponent_ReceivesNamedCascadingValues()
{
var cut = RenderComponent<DashboardWidget>(parameters => parameters
.AddCascadingValue("PageTitle", "Dashboard")
.AddCascadingValue("SidebarCollapsed", true));
Assert.Contains("Dashboard", cut.Find("[data-testid='widget-title']").TextContent);
}JavaScript Interop Mocking
Blazor components that call JavaScript via IJSRuntime require mock setup in bUnit. bUnit provides a built-in JS interop mock.
Basic JSInterop Setup
public class ClipboardButtonTests : TestContext
{
[Fact]
public void CopyButton_Click_InvokesClipboardAPI()
{
// Set up JS interop mock -- bUnit's JSInterop is available via this.JSInterop
JSInterop.SetupVoid("navigator.clipboard.writeText", "Hello, World!");
var cut = RenderComponent<CopyButton>(parameters => parameters
.Add(p => p.TextToCopy, "Hello, World!"));
cut.Find("[data-testid='copy-btn']").Click();
// Verify the JS call was made
JSInterop.VerifyInvoke("navigator.clipboard.writeText", calledTimes: 1);
}
}JSInterop with Return Values
[Fact]
public void GeoLocation_OnLoad_DisplaysCoordinates()
{
// Mock JS call that returns a value
var location = new { Latitude = 47.6062, Longitude = -122.3321 };
JSInterop.Setup<object>("getGeoLocation").SetResult(location);
var cut = RenderComponent<LocationDisplay>();
cut.WaitForState(() => cut.Find("[data-testid='coordinates']").TextContent.Contains("47.6"));
Assert.Contains("47.6062", cut.Find("[data-testid='coordinates']").TextContent);
}Catch-All JSInterop Mode
For components with many JS calls, use loose mode to avoid setting up every call:
[Fact]
public void RichEditor_Render_DoesNotThrowJSErrors()
{
// Loose mode: unmatched JS calls return default values instead of throwing
JSInterop.Mode = JSRuntimeMode.Loose;
var cut = RenderComponent<RichTextEditor>(parameters => parameters
.Add(p => p.Content, "Initial content"));
// Component renders without JS exceptions
Assert.NotEmpty(cut.Markup);
}Async Component Lifecycle
Testing OnInitializedAsync
[Fact]
public void ProductList_WhileLoading_ShowsSpinner()
{
var tcs = new TaskCompletionSource<List<ProductDto>>();
var productService = Substitute.For<IProductService>();
productService.GetProductsAsync().Returns(tcs.Task);
Services.AddSingleton(productService);
var cut = RenderComponent<ProductList>();
// Component is still loading -- spinner should be visible
Assert.NotNull(cut.Find("[data-testid='loading-spinner']"));
// Complete the async operation
tcs.SetResult([new ProductDto { Name = "Widget", Price = 9.99m }]);
cut.WaitForState(() => cut.FindAll("[data-testid='product-item']").Count > 0);
// Spinner gone, products visible
Assert.Throws<ElementNotFoundException>(
() => cut.Find("[data-testid='loading-spinner']"));
Assert.Single(cut.FindAll("[data-testid='product-item']"));
}Testing Error States
[Fact]
public void ProductList_ServiceError_ShowsErrorMessage()
{
var productService = Substitute.For<IProductService>();
productService.GetProductsAsync()
.ThrowsAsync(new HttpRequestException("Service unavailable"));
Services.AddSingleton(productService);
var cut = RenderComponent<ProductList>();
cut.WaitForState(() =>
cut.Find("[data-testid='error-message']").TextContent.Length > 0);
Assert.Contains("Service unavailable",
cut.Find("[data-testid='error-message']").TextContent);
}Key Principles
- Render components in isolation. bUnit tests individual components without a browser, making them fast and deterministic. Use this for component logic; use [skill:dotnet-testing] for full-app E2E flows.
- Register all dependencies before rendering. Any service the component injects via
[Inject]must be registered inServicesbeforeRenderComponentis called. - Use `WaitForState` and `WaitForAssertion` for async components. Do not use
Task.Delay-- bUnit provides purpose-built waiting mechanisms. - Mock JS interop explicitly. Unhandled JS interop calls throw by default in bUnit strict mode. Set up expected calls or switch to loose mode for JS-heavy components.
- Test the rendered output, not component internals. Assert on markup, text content, and element attributes -- not on private fields or internal state.
Agent Gotchas
1. Do not forget to register services before `RenderComponent`. bUnit throws at render time if an [Inject]-ed service is missing. Register mocks or fakes for every injected dependency. 2. Do not use `cut.Instance` to access private members. Instance exposes the component's public API only. If you need to test internal state, expose it through public properties or test through rendered output. 3. Do not forget to call `cut.WaitForState()` after triggering async operations. Without it, assertions run before the component re-renders, causing false failures. 4. Do not mix bUnit and Playwright in the same test class. bUnit runs components in-memory (no browser); Playwright runs in a real browser. They serve different purposes and have incompatible lifecycles. 5. Do not forget cascading values for components that expect them. A component with [CascadingParameter] will receive null if no CascadingValue is provided, which may cause NullReferenceException during rendering.
References
Localization
Comprehensive .NET internationalization and localization: .resx resource files and satellite assemblies, modern alternatives (JSON resources, source generators for AOT), IStringLocalizer patterns, date/number/currency formatting with CultureInfo, RTL layout support, pluralization engines, and per-framework localization integration for Blazor, MAUI, Uno Platform, and WPF.
Version assumptions: .NET 8.0+ baseline. IStringLocalizer stable since .NET Core 1.0; localization APIs stable since .NET 5. .NET 9+ features explicitly marked.
.resx Resource Files
Overview
Resource files (.resx) are the standard .NET localization format. They compile into satellite assemblies resolved by ResourceManager with automatic culture fallback.
Culture Fallback Chain
Resources resolve in order of specificity, falling back until a match is found:
sr-Cyrl-RS.resx -> sr-Cyrl.resx -> sr.resx -> Resources.resx (default/neutral)The default .resx file (no culture suffix) is the single source of truth. Translation files must not contain keys absent from the default file.
Project Setup
<!-- MyApp.csproj -->
<PropertyGroup>
<NeutralLanguage>en-US</NeutralLanguage>
</PropertyGroup>
<ItemGroup>
<!-- Default resources -->
<EmbeddedResource Include="Resources\Messages.resx" />
<!-- Culture-specific resources -->
<EmbeddedResource Include="Resources\Messages.fr-FR.resx" />
<EmbeddedResource Include="Resources\Messages.de-DE.resx" />
</ItemGroup>Resource File Structure
<!-- Resources/Messages.resx (default/neutral) -->
<?xml version="1.0" encoding="utf-8"?>
<root>
<data name="Welcome" xml:space="preserve">
<value>Welcome to the application</value>
<comment>Shown on the home page</comment>
</data>
<data name="ItemCount" xml:space="preserve">
<value>You have {0} item(s)</value>
<comment>{0} = number of items</comment>
</data>
</root>Accessing Resources
// Via generated strongly-typed class (ResXFileCodeGenerator custom tool)
string welcome = Messages.Welcome;
// Via ResourceManager directly
var rm = new ResourceManager("MyApp.Resources.Messages",
typeof(Messages).Assembly);
string welcome = rm.GetString("Welcome", CultureInfo.CurrentUICulture);Modern Alternatives
JSON-Based Resources
Lightweight alternative for projects already using JSON for configuration. Libraries provide IStringLocalizer implementations backed by JSON files.
// Resources/en-US.json
{
"Welcome": "Welcome to the application",
"ItemCount": "You have {0} item(s)"
}Libraries:
Senlin.Mo.Localization-- JSON-backedIStringLocalizerEmbedded.Json.Localization-- embedded JSON resources
JSON resources are popular in ASP.NET Core but lack the built-in tooling support (Visual Studio designer, satellite assembly compilation) of .resx.
Source Generators for AOT Compatibility
Traditional .resx with ResourceManager uses reflection at runtime, which is problematic for Native AOT and trimming. Source generators eliminate runtime reflection by generating strongly-typed accessor classes at compile time.
Recommended source generators:
| Generator | Description | AOT-Safe |
|---|---|---|
| ResXGenerator (ycanardeau) | Strongly-typed classes with IStringLocalizer support and DI registration | Yes |
| VocaDb.ResXFileCodeGenerator | Original strongly-typed .resx source generator | Yes |
Built-in ResXFileCodeGenerator | Visual Studio custom tool (not a Roslyn source generator) | No -- generates static properties but still uses ResourceManager |
<!-- Using ResXGenerator -->
<ItemGroup>
<PackageReference Include="ResXGenerator" Version="1.*"
PrivateAssets="all" />
</ItemGroup>// Generated at compile time -- no runtime reflection
string welcome = Messages.Welcome;
// With DI registration (ResXGenerator)
services.AddResXLocalization();Recommendation: Use .resx files as the resource format (broadest tooling support) with a source generator for AOT/trimming scenarios. Use JSON resources only for lightweight or config-heavy projects.
IStringLocalizer Patterns
Registration
var builder = WebApplication.CreateBuilder(args);
// Register localization services
builder.Services.AddLocalization(options =>
options.ResourcesPath = "Resources");
var app = builder.Build();
// Configure request localization middleware
var supportedCultures = new[] { "en-US", "fr-FR", "de-DE", "ja-JP" };
app.UseRequestLocalization(options =>
{
options.SetDefaultCulture(supportedCultures[0])
.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(supportedCultures);
});IStringLocalizer<T>
The primary localization interface. Injectable via DI. Use everywhere: services, controllers, Blazor components, middleware.
public class OrderService
{
private readonly IStringLocalizer<OrderService> _localizer;
public OrderService(IStringLocalizer<OrderService> localizer)
{
_localizer = localizer;
}
public string GetConfirmation(int orderId)
{
// Indexer returns LocalizedString with implicit string conversion
return _localizer["OrderConfirmed", orderId];
// Resolves: "Order {0} confirmed" with orderId substituted
}
public bool IsTranslated(string key)
{
LocalizedString result = _localizer[key];
return !result.ResourceNotFound;
}
}IViewLocalizer (MVC Razor Views Only)
Auto-resolves resource files matching the view path. Not supported in Blazor.
@* Views/Home/Index.cshtml *@
@inject IViewLocalizer Localizer
<h1>@Localizer["Welcome"]</h1>
<p>@Localizer["ItemCount", Model.Count]</p>Resource file location: Resources/Views/Home/Index.en-US.resx
IHtmlLocalizer (MVC Only)
HTML-aware variant that HTML-encodes format arguments but preserves HTML in the resource string itself. Not supported in Blazor.
@inject IHtmlLocalizer<SharedResource> HtmlLocalizer
@* Resource: "Read our <a href='/terms'>terms</a>, {0}" *@
@* {0} is HTML-encoded, the <a> tag is preserved *@
<p>@HtmlLocalizer["TermsNotice", Model.UserName]</p>When to Use Each
| Interface | Scope | HTML-Safe | Blazor | MVC |
|---|---|---|---|---|
IStringLocalizer<T> | Everywhere | No (plain text) | Yes | Yes |
IViewLocalizer | View-local strings | No | No | Yes |
IHtmlLocalizer<T> | HTML in resources | Yes | No | Yes |
Namespace Resolution
If resource lookup fails, check namespace alignment. IStringLocalizer<T> resolves resources using the full type name of T relative to the ResourcesPath. Use RootNamespaceAttribute to fix namespace/assembly mismatches:
[assembly: RootNamespace("MyApp")]Date, Number, and Currency Formatting
CultureInfo
CultureInfo is the central class for culture-specific formatting. Two distinct properties control behavior:
CultureInfo.CurrentCulture-- controls formatting (dates, numbers, currency)CultureInfo.CurrentUICulture-- controls resource lookup (which.resxfile)
// Always pass explicit CultureInfo -- never rely on thread defaults in server code
var date = DateTime.Now.ToString("D", new CultureInfo("fr-FR"));
// "vendredi 14 fevrier 2026"
var price = 1234.56m.ToString("C", new CultureInfo("de-DE"));
// "1.234,56 EUR" (uses NumberFormatInfo.CurrencySymbol)
var number = 1234567.89.ToString("N2", new CultureInfo("ja-JP"));
// "1,234,567.89"Server-Side Best Practices
// Use useUserOverride: false in server scenarios to avoid
// picking up user-customized formats
var culture = new CultureInfo("en-US", useUserOverride: false);
// Set culture per-request (ASP.NET Core middleware handles this)
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;Format Specifiers
| Specifier | Type | Example (en-US) | Example (de-DE) |
|---|---|---|---|
"d" | Short date | 2/14/2026 | 14.02.2026 |
"D" | Long date | Friday, February 14, 2026 | Freitag, 14. Februar 2026 |
"C" | Currency | $1,234.56 | 1.234,56 EUR |
"N2" | Number | 1,234.57 | 1.234,57 |
"P1" | Percent | 85.5% | 85,5 % |
RTL Support
Detecting RTL Cultures
bool isRtl = CultureInfo.CurrentCulture.TextInfo.IsRightToLeft;
// true for: ar-*, he-*, fa-*, ur-*, etc.Per-Framework RTL Patterns
Blazor: No native FlowDirection -- use CSS dir attribute:
// wwwroot/js/app.js
window.setDocumentDirection = (dir) => document.documentElement.dir = dir;// Set via named JS function (avoid eval -- causes CSP unsafe-eval violations)
await JSRuntime.InvokeVoidAsync("setDocumentDirection",
isRtl ? "rtl" : "ltr");For deep Blazor component patterns, see references/blazor-components.md.
MAUI: FlowDirection property on VisualElement and Window:
// Set at window level -- cascades to all children
window.FlowDirection = isRtl
? FlowDirection.RightToLeft
: FlowDirection.LeftToRight;Android requires android:supportsRtl="true" in AndroidManifest.xml (set by default in MAUI). For deep MAUI patterns, see references/maui-development.md.
Uno Platform: Inherits WinUI FlowDirection model:
<Page FlowDirection="RightToLeft">
<!-- All children inherit RTL layout -->
</Page>For Uno Extensions and x:Uid binding, see references/uno-platform.md.
WPF: FlowDirection property on FrameworkElement:
<Window FlowDirection="RightToLeft">
<!-- All children inherit RTL layout -->
</Window>For WPF on modern .NET patterns, see references/wpf-modern.md.
Pluralization
The Problem
Simple string interpolation fails for pluralization across languages:
// WRONG: English-only, breaks in languages with complex plural rules
$"You have {count} item{(count != 1 ? "s" : "")}"Languages like Arabic have six plural forms (zero, one, two, few, many, other). Polish distinguishes "few" from "many" based on number ranges.
ICU MessageFormat (MessageFormat.NET)
CLDR-compliant pluralization using ICU plural categories. Recommended for internationalization-first projects.
// Package: jeffijoe/messageformat.net (v5.0+, ships CLDR pluralizers)
var formatter = new MessageFormatter();
string pattern = "{count, plural, " +
"=0 {No items}" +
"one {# item}" +
"other {# items}}";
formatter.Format(pattern, new { count = 0 }); // "No items"
formatter.Format(pattern, new { count = 1 }); // "1 item"
formatter.Format(pattern, new { count = 42 }); // "42 items"SmartFormat.NET
Flexible text templating with built-in pluralization. Good for projects wanting maximum flexibility.
// Package: axuno/SmartFormat (v3.6.1+)
using SmartFormat;
Smart.Format("{count:plural:No items|# item|# items}",
new { count = 0 }); // "No items"
Smart.Format("{count:plural:No items|# item|# items}",
new { count = 1 }); // "1 item"
Smart.Format("{count:plural:No items|# item|# items}",
new { count = 5 }); // "5 items"Choosing a Pluralization Engine
| Engine | CLDR Compliance | API Style | Best For |
|---|---|---|---|
| MessageFormat.NET | Full (CLDR categories) | ICU pattern strings | Multi-locale apps needing standard compliance |
| SmartFormat.NET | Partial (extensible) | .NET format string extension | Flexible templating with pluralization |
| Manual conditional | None | string.Format + branching | Simple English-only dual forms |
UI Framework Integration
Blazor Localization
Blazor supports IStringLocalizer only -- IHtmlLocalizer and IViewLocalizer are not available.
Component injection:
@inject IStringLocalizer<MyComponent> Loc
<h1>@Loc["Welcome"]</h1>
<p>@Loc["ItemCount", items.Count]</p>Culture configuration by render mode:
| Render Mode | Culture Source |
|---|---|
| Server / SSR | RequestLocalizationMiddleware (server-side) |
| WebAssembly | CultureInfo.DefaultThreadCurrentCulture + Blazor start option applicationCulture |
| Auto | Both -- server middleware for initial load, WASM culture for client-side |
WASM globalization data:
<!-- Required for full ICU data in Blazor WASM -->
<PropertyGroup>
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
</PropertyGroup>Without this property, Blazor WASM loads only a subset of ICU data. For minimal download size, use InvariantGlobalization=true (disables localization entirely).
Dynamic culture switching:
// CultureSelector component pattern:
// 1. Store selected culture in browser local storage
// 2. Set culture cookie via controller redirect (server-side)
// 3. Read cookie in RequestLocalizationMiddlewareFor deep Blazor component patterns (lifecycle, state management, JS interop), see references/blazor-components.md.
MAUI Localization
MAUI uses .resx files with strongly-typed generated properties.
Resource setup:
Resources/
Strings/
AppResources.resx # Default (neutral) culture
AppResources.fr-FR.resx # French
AppResources.ja-JP.resx # JapaneseXAML binding:
<!-- Import namespace -->
<ContentPage xmlns:strings="clr-namespace:MyApp.Resources.Strings">
<!-- Use x:Static for strongly-typed access -->
<Label Text="{x:Static strings:AppResources.Welcome}" />
<Button Text="{x:Static strings:AppResources.LoginButton}" />
</ContentPage>Code access:
string welcome = AppResources.Welcome;Platform requirements:
- iOS/Mac Catalyst: Add
CFBundleLocalizationstoInfo.plist - Windows: Add
<Resource Language="...">entries toPackage.appxmanifest - All platforms: Set
<NeutralLanguage>en-US</NeutralLanguage>in csproj
For deep MAUI development patterns (controls, navigation, platform APIs), see references/maui-development.md.
Uno Platform Localization
Uno uses .resw files (Windows resource format) with x:Uid for automatic XAML resource binding.
Resource structure:
Strings/
en/Resources.resw
fr-FR/Resources.resw
ja-JP/Resources.reswRegistration:
// In Host builder configuration
.UseLocalization()XAML binding with x:Uid:
<!-- x:Uid maps to resource keys: "MainPage_Title.Text", "LoginButton.Content" -->
<TextBlock x:Uid="MainPage_Title" />
<Button x:Uid="LoginButton" />Runtime culture switching:
var localizationService = serviceProvider
.GetRequiredService<ILocalizationService>();
await localizationService.SetCurrentCultureAsync(
new CultureInfo("fr-FR"));
// Note: XAML x:Uid bindings retain old culture until app restartKnown limitation: x:Uid-based localization keeps the old culture until app restart, even after calling SetCurrentCultureAsync. Code-based IStringLocalizer updates immediately.
For Uno Extensions ecosystem configuration and MVUX patterns, see references/uno-platform.md.
WPF Localization
Recommended approach for .NET 8+: .resx files with DynamicResource binding for runtime locale switching. Avoid LocBaml (works only on .NET Framework).
Resource dictionary approach:
<!-- Resources/Strings.en-US.xaml -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:sys="clr-namespace:System;assembly=System.Runtime">
<sys:String x:Key="Welcome">Welcome</sys:String>
<sys:String x:Key="LoginButton">Log In</sys:String>
</ResourceDictionary><!-- MainWindow.xaml -->
<TextBlock Text="{DynamicResource Welcome}" />
<Button Content="{DynamicResource LoginButton}" />Runtime locale switching:
// Swap resource dictionary at runtime
var dict = new ResourceDictionary
{
Source = new Uri($"Resources/Strings.{cultureName}.xaml",
UriKind.Relative)
};
Application.Current.Resources.MergedDictionaries.Clear();
Application.Current.Resources.MergedDictionaries.Add(dict);ResX approach (simpler, works on all .NET versions):
// Standard .resx with generated class
string welcome = Strings.Welcome;
// Runtime switch
Thread.CurrentThread.CurrentUICulture = new CultureInfo("fr-FR");
// Re-read after culture change
string welcomeFr = Strings.Welcome; // Now returns FrenchCommunity options:
- WPF Localization Extensions -- RESX files with XAML markup extensions for declarative localization
- LocBamlCore (h3xds1nz) -- unofficial port supporting .NET 9, for BAML localization on modern .NET
For WPF Host builder, MVVM Toolkit, and theming patterns, see references/wpf-modern.md.
Agent Gotchas
1. Do not use `IHtmlLocalizer` or `IViewLocalizer` in Blazor. These are MVC-only features. Use IStringLocalizer<T> in Blazor components. 2. Do not rely on `CultureInfo.CurrentCulture` thread defaults in server code. Always pass explicit CultureInfo to formatting methods. Server thread culture may not match the request culture. 3. Do not hardcode plural forms. English "singular/plural" does not work for Arabic (6 forms), Polish, or other languages. Use MessageFormat.NET or SmartFormat.NET for proper CLDR pluralization. 4. Do not use LocBaml for WPF on .NET 8+. LocBaml is a .NET Framework-only sample tool. Use .resx files or resource dictionaries for modern WPF. 5. Do not forget `BlazorWebAssemblyLoadAllGlobalizationData` for Blazor WASM. Without it, only partial ICU data is loaded, causing incorrect date/number formatting for many cultures. 6. Do not add translation keys absent from the default `.resx` file. The default resource is the single source of truth; satellite assemblies must be a subset. 7. Do not use `ResourceManager` directly in AOT/trimmed apps. It relies on reflection. Use a source generator (ResXGenerator) for compile-time resource access. 8. Do not forget platform-specific setup for MAUI. iOS/Mac Catalyst need CFBundleLocalizations in Info.plist; Windows needs Resource Language entries.
MAUI AOT
Native AOT compilation for .NET MAUI on iOS and Mac Catalyst: compilation pipeline, publish profiles, up to 50% app size reduction and up to 50% startup improvement, library compatibility gaps, opt-out mechanisms, trimming interplay (RD.xml, source generators), and testing AOT builds on device.
Version assumptions: .NET 8.0+ baseline. Native AOT for MAUI is available on iOS and Mac Catalyst. Android uses a different compilation model (CoreCLR in .NET 11, Mono/AOT in .NET 8-10).
iOS/Mac Catalyst AOT Compilation Pipeline
Native AOT on iOS and Mac Catalyst compiles .NET IL directly to native machine code at publish time, eliminating the need for a JIT compiler or IL interpreter at runtime. This produces a self-contained native binary that links against platform frameworks.
How It Works
1. IL compilation: The .NET IL is compiled to native code by the NativeAOT compiler (ILC) 2. Tree shaking: Unused code is trimmed based on static analysis of reachable types and methods 3. Native linking: The generated native code is linked with iOS/Catalyst frameworks and the minimal .NET runtime 4. App bundle: The result is a standard .app bundle with a native executable (no IL assemblies shipped)
Publish Configuration
<!-- Enable Native AOT for iOS/Mac Catalyst -->
<PropertyGroup Condition="'$(TargetFramework)' == 'net8.0-ios' Or
'$(TargetFramework)' == 'net8.0-maccatalyst'">
<PublishAot>true</PublishAot>
<!-- Optional: strip debug symbols for smaller binary -->
<StripSymbols>true</StripSymbols>
</PropertyGroup># Publish with AOT for iOS
dotnet publish -f net8.0-ios -c Release -r ios-arm64
# Publish with AOT for Mac Catalyst
dotnet publish -f net8.0-maccatalyst -c Release -r maccatalyst-arm64
# Publish for iOS simulator (for AOT testing without device)
dotnet publish -f net8.0-ios -c Release -r iossimulator-arm64Entitlements and Provisioning
AOT builds require the same entitlements and provisioning profiles as regular iOS/Catalyst builds. No additional entitlements are needed for AOT specifically.
<!-- iOS entitlements (Entitlements.plist) -->
<!-- Standard entitlements; AOT does not require special entries -->Size Reduction
Native AOT can achieve up to 50% app size reduction compared to interpreter/JIT mode on iOS. The size improvement comes from:
- Tree shaking: Only reachable code is included in the final binary
- No IL shipping: The app bundle does not include .NET IL assemblies
- No runtime JIT: The JIT compiler and associated metadata are not packaged
Typical Size Comparison
| Mode | Approximate Size | Notes |
|---|---|---|
| Interpreter (default .NET 8 iOS) | ~60-80 MB | Includes IL assemblies + interpreter |
| Native AOT | ~30-45 MB | Native binary only, no IL |
| Native AOT + StripSymbols | ~25-40 MB | Debug symbols stripped |
Caveat: Actual size reduction depends on app complexity, third-party library usage, and how much code is reachable after trimming. Libraries that use heavy reflection may prevent aggressive trimming and reduce size gains.
Startup Improvement
Native AOT provides up to 50% faster cold startup on iOS and Mac Catalyst. The startup improvement comes from:
- No JIT warmup: Code is already native; no compilation at app launch
- No IL loading: No need to load and parse .NET assemblies
- Reduced memory pressure: Smaller working set during startup
Measuring Startup
// Instrument startup timing
public partial class App : Application
{
private static readonly long StartTicks = Stopwatch.GetTimestamp();
public App()
{
InitializeComponent();
MainPage = new AppShell();
var elapsed = Stopwatch.GetElapsedTime(StartTicks);
System.Diagnostics.Debug.WriteLine(
$"App startup: {elapsed.TotalMilliseconds:F0}ms");
}
}# Use Xcode Instruments for precise startup measurement
# Time Profiler template → measure "pre-main" + "post-main" time
# Compare AOT vs non-AOT builds on the same deviceLibrary Compatibility
Many .NET libraries are not fully AOT-compatible. Common compatibility issues stem from:
- Reflection: Runtime type inspection,
Type.GetType(),Activator.CreateInstance() - Dynamic code generation:
System.Reflection.Emit,System.Linq.Expressions.Compile() - Serialization without source generators: JSON/XML serializers that use reflection
Compatibility Matrix
| Library / Feature | AOT Status | Workaround |
|---|---|---|
| System.Text.Json (source gen) | Compatible | Use [JsonSerializable] context |
| System.Text.Json (reflection) | Breaks | Switch to source generators |
| CommunityToolkit.Mvvm | Compatible | Source-gen based, AOT-safe |
| Entity Framework Core | Partial | Precompiled queries; no dynamic LINQ |
| Newtonsoft.Json | Breaks | Migrate to System.Text.Json with source gen |
| AutoMapper | Breaks | Use Mapperly (source gen) |
| MediatR | Partial | Register handlers explicitly, avoid assembly scanning |
| HttpClient | Compatible | Standard usage works |
| MAUI Essentials | Compatible | Platform APIs are AOT-safe |
| SQLite-net | Compatible | Uses P/Invoke, AOT-safe |
| Refit | Breaks | Use Refit 7+ (includes source generator; enable with [GenerateRefitClient]) |
| FluentValidation | Partial | Avoid runtime expression compilation |
Detecting Incompatible Code
<!-- Enable AOT analysis warnings during development -->
<PropertyGroup>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
<!-- Also enable trim analyzer (AOT requires trimming) -->
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
</PropertyGroup>AOT analysis produces warnings like IL3050 (RequiresDynamicCode) and IL2026 (RequiresUnreferencedCode). Address these before publishing with AOT.
Opt-Out Mechanisms
Disabling AOT Entirely
<!-- Disable Native AOT (use interpreter/JIT mode) -->
<PropertyGroup>
<PublishAot>false</PublishAot>
</PropertyGroup>Per-Assembly Trimming Overrides
When a specific library is not AOT-compatible, you can preserve it from trimming while still using AOT for the rest of the app:
<!-- Preserve a specific assembly from trimming -->
<ItemGroup>
<TrimmerRootAssembly Include="IncompatibleLibrary" />
</ItemGroup>Opt-Out of .NET 11 Defaults
.NET 11 introduces new defaults that interact with AOT:
<!-- Revert XAML source gen (use legacy XAMLC) -->
<PropertyGroup>
<MauiXamlInflator>XamlC</MauiXamlInflator>
</PropertyGroup>
<!-- Revert to Mono runtime on Android (not related to iOS AOT,
but relevant for the overall MAUI AOT story) -->
<PropertyGroup>
<UseMonoRuntime>true</UseMonoRuntime>
</PropertyGroup>Trimming Interplay
Native AOT requires trimming. When PublishAot is true, trimming is automatically enabled. Understanding trimming configuration is essential for a successful AOT build.
ILLink Descriptors for Reflection Preservation
Note: In Xamarin/Mono-era documentation, these were called "rd.xml" (Runtime Directives). In .NET 8+ Native AOT, use ILLink descriptor XML files instead.
When code uses reflection that the trimmer cannot statically analyze, use an ILLink descriptor XML file to preserve types. You can also use [DynamicDependency] attributes for fine-grained preservation in code.
ILLink descriptor XML (preferred for bulk preservation):
<!-- ILLink.Descriptors.xml -- preserve types needed at runtime -->
<linker>
<!-- Preserve all public members of a type -->
<assembly fullname="MyApp">
<type fullname="MyApp.Models.LegacyConfig" preserve="all" />
<type fullname="MyApp.Services.PluginLoader">
<method name="LoadPlugin" />
</type>
</assembly>
<!-- Preserve all types in an external assembly -->
<assembly fullname="IncompatibleLibrary" preserve="all" />
</linker><!-- Register the descriptor in .csproj -->
<ItemGroup>
<TrimmerRootDescriptor Include="ILLink.Descriptors.xml" />
</ItemGroup>`[DynamicDependency]` attribute (preferred for targeted preservation):
using System.Diagnostics.CodeAnalysis;
// Preserve a specific method on a type
[DynamicDependency(nameof(LegacyConfig.Initialize), typeof(LegacyConfig))]
public void ConfigureApp() { /* ... */ }
// Preserve all public members of a type
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(LegacyConfig))]
public void LoadPlugins() { /* ... */ }Source Generator Alternatives
When source generators aren't available, use [DynamicDependency] attributes (shown above) for targeted preservation without ILLink XML files.
Prefer source generators over reflection to avoid trimming issues entirely:
| Reflection Pattern | Source Generator Alternative |
|---|---|
JsonSerializer.Deserialize<T>() | [JsonSerializable] context (System.Text.Json) |
Activator.CreateInstance<T>() | Factory pattern with explicit registration |
Type.GetProperties() | CommunityToolkit.Mvvm [ObservableProperty] |
| Assembly scanning for DI | Explicit services.Add*() registrations |
| AutoMapper reflection mapping | Mapperly [Mapper] source generator |
Trimming Warnings
# Build with detailed trim warnings
dotnet publish -f net8.0-ios -c Release /p:PublishAot=true /p:TrimmerSingleWarn=false
# TrimmerSingleWarn=false shows per-occurrence warnings instead of
# one summary warning per assembly, making it easier to fix issuesCommon trim warnings:
- IL2026: Member with
RequiresUnreferencedCode-- the member does something not guaranteed to work after trimming - IL2046: Trim attribute mismatch between base/derived types
- IL3050: Member with
RequiresDynamicCode-- the member generates code at runtime (incompatible with AOT)
Testing AOT Builds
AOT builds can behave differently from Debug/JIT builds. Always test on a real device or simulator with an AOT-published build before release.
Common AOT-Only Failures
| Failure | Symptom | Fix |
|---|---|---|
| Missing type metadata | MissingMetadataException at runtime | Add type to ILLink descriptor or use [DynamicDependency] |
| Trimmed method | MissingMethodException | Add [DynamicDependency] or ILLink descriptor entry |
| Dynamic code gen | PlatformNotSupportedException | Replace with source generator alternative |
| Reflection-based serialization | Empty/null deserialized objects | Use [JsonSerializable] source gen |
| Assembly scanning | Missing services at runtime | Register services explicitly in DI |
Testing Workflow
# 1. Build and publish with AOT for simulator (faster iteration)
dotnet publish -f net8.0-ios -c Release -r iossimulator-arm64
# 2. Install and test on simulator
# (Use Xcode or Visual Studio to deploy the .app to simulator)
# 3. Run smoke tests -- focus on:
# - App startup (no MissingMetadataException)
# - JSON deserialization (all properties populated)
# - Navigation (all pages render)
# - Platform services (biometric, camera, location)
# - Third-party SDK integration
# 4. Test on physical device before release
dotnet publish -f net8.0-ios -c Release -r ios-arm64
# Deploy via Xcode with provisioning profileCI Integration
# CI pipeline: build AOT and run device tests via XHarness
dotnet publish -f net8.0-ios -c Release -r iossimulator-arm64 /p:PublishAot=true
xharness apple test \
--app bin/Release/net8.0-ios/iossimulator-arm64/publish/MyApp.app \
--target ios-simulator-64 \
--timeout 00:10:00 \
--output-directory test-results/aotFor MAUI testing patterns (Appium, XHarness), see references/maui-testing.md.
Agent Gotchas
1. Do not enable `PublishAot` without also enabling trim analyzers. AOT requires trimming. Set <EnableTrimAnalyzer>true</EnableTrimAnalyzer> and <EnableAotAnalyzer>true</EnableAotAnalyzer> during development to catch issues early. 2. Do not assume all NuGet packages are AOT-compatible. Check for IsAotCompatible in the package's .csproj or look for trim/AOT warnings when building. Many popular packages still use reflection internally. 3. Do not use `Newtonsoft.Json` with AOT. It relies entirely on reflection. Migrate to System.Text.Json with [JsonSerializable] source gen contexts for AOT-safe serialization. 4. Do not skip device testing for AOT builds. Simulator testing catches most issues, but physical device behavior can differ -- especially for startup timing, memory constraints, and platform service integration. 5. Do not confuse MAUI iOS AOT with Android AOT. MAUI Native AOT (PublishAot) targets iOS and Mac Catalyst only. Android uses a different compilation model (Mono AOT in .NET 8-10, CoreCLR in .NET 11+). They are configured separately.
Prerequisites
- .NET 8.0+ with MAUI workload
- Xcode and iOS/Mac Catalyst SDKs (macOS only)
- Apple Developer account for physical device deployment
- Provisioning profile and signing certificate for device testing