.NET MAUI (.NET Multi-platform App UI) is Microsoft's framework for building native Android, iOS, macOS and Windows apps from one C# and XAML codebase. It suits teams that already know .NET and want to ship mobile and desktop clients without maintaining separate Kotlin, Swift and WinUI projects. This guide explains how .NET MAUI works under the hood, how to structure an app with MVVM and Shell, how to reach platform APIs, how to make it fast, and what changed in .NET 10 and the upcoming .NET 11.

What Is .NET MAUI?#

.NET MAUI is the successor to Xamarin.Forms. It keeps the same idea, a cross-platform UI abstraction that renders real native controls, but rebuilds it on modern .NET with a single project, a new handler architecture and first-class dependency injection. Your code compiles against platform-specific target frameworks such as net10.0-android and net10.0-ios, so every app is a genuine native app that can call any platform API.

A Button in .NET MAUI becomes a MaterialButton on Android, a UIButton on iOS and Mac Catalyst, and a WinUI Button on Windows. That design gives you platform-appropriate behavior, accessibility and text input for free. The trade-off is that pixel-identical rendering across platforms is not a goal; if you need that, look at drawn-UI frameworks such as Avalonia or Uno Platform's Skia renderer, compared in our cross-platform UI comparison.

Supported targets for .NET MAUI 10 are Android 5.0 (API 21) or later, iOS 12.2 or later, macOS 12 or later through Mac Catalyst, and Windows 10 version 1809 or later through WinUI 3. Samsung maintains Tizen support. Linux and the browser are not supported targets.

How .NET MAUI Works: Single Project and Handlers#

One project, many target frameworks#

A .NET MAUI app is one project that multi-targets. Shared code lives at the root, and each platform has a folder under Platforms/ for its entry point, manifest and platform-only code. Files in Platforms/Android compile only for the Android target, which removes most of the #if clutter older Xamarin apps needed. Images, fonts, the app icon and the splash screen are declared once as MSBuild items, and the build generates every density and format each platform expects.

XML
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
    <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">
      $(TargetFrameworks);net10.0-windows10.0.19041.0
    </TargetFrameworks>
    <OutputType>Exe</OutputType>
    <UseMaui>true</UseMaui>
    <SingleProject>true</SingleProject>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <ApplicationTitle>Field Tasks</ApplicationTitle>
    <ApplicationId>com.contoso.fieldtasks</ApplicationId>
    <ApplicationDisplayVersion>1.4.0</ApplicationDisplayVersion>
    <ApplicationVersion>14</ApplicationVersion>
    <WindowsPackageType>None</WindowsPackageType>
  </PropertyGroup>

  <ItemGroup>
    <MauiIcon Include="Resources\AppIcon\appicon.svg"
              ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
    <MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
    <MauiImage Include="Resources\Images\*" />
    <MauiFont Include="Resources\Fonts\*" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
    <PackageReference Include="CommunityToolkit.Maui" Version="15.0.1" />
  </ItemGroup>
</Project>

Handlers and property mappers#

Xamarin.Forms used renderers: heavyweight classes that wrapped each native control and were hard to customize. .NET MAUI replaces them with handlers. A handler maps a cross-platform interface such as IEntry to a native view through a property mapper, a dictionary of small functions that each push one property to the native control. Because mappers are public, you can append, prepend or replace a mapping for every instance of a control without subclassing anything.

Each handler exposes the native control through PlatformView and the cross-platform element through VirtualView. Since .NET 9, handlers disconnect automatically when a page is popped, so native event subscriptions are released unless you set HandlerProperties.DisconnectPolicy to Manual.

Runtimes per platform#

.NET MAUI 10 runs on Mono on Android, iOS and Mac Catalyst, and on CoreCLR on Windows. iOS forbids JIT compilation, so Apple builds are ahead-of-time compiled, and Native AOT is an opt-in for iOS and Mac Catalyst since .NET 9. .NET 11 makes CoreCLR the default runtime on Android and Apple platforms as well, with Native AOT as an opt-in publishing mode, which unifies diagnostics and runtime behavior across every target.

Getting Started: Bootstrapping with MauiProgram#

Install the workload with dotnet workload install maui, then create a project with dotnet new maui -n FieldTasks. Adding --sample-content generates a richer starter app that already uses the MVVM Toolkit and a SQLite store. Everything starts in MauiProgram.CreateMauiApp, which configures a MauiAppBuilder that works like the .NET generic host: services, logging and configuration all use the familiar Microsoft.Extensions APIs.

C#
using CommunityToolkit.Maui;
using FieldTasks.Services;
using FieldTasks.ViewModels;
using FieldTasks.Views;
using Microsoft.Extensions.Logging;

namespace FieldTasks;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .UseMauiCommunityToolkit()
            .ConfigureFonts(fonts => fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"));

        builder.Services.AddSingleton<IGeolocation>(Geolocation.Default);
        builder.Services.AddSingleton<ITaskStore, SqliteTaskStore>();
        builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
        builder.Services.AddSingleton<LocationService>();

        builder.Services.AddTransient<TaskListViewModel>();
        builder.Services.AddTransient<TaskListPage>();
        builder.Services.AddTransient<TaskDetailViewModel>();
        builder.Services.AddTransient<TaskDetailPage>();

#if DEBUG
        builder.Logging.AddDebug();
#endif
        return builder.Build();
    }
}

Register platform services through their interfaces, as with IGeolocation here, rather than calling static Default properties from view models. The interface keeps view models testable. For container lifetimes and pitfalls such as captive dependencies, see the dependency injection guide.

Building the UI with XAML and C# Markup#

XAML with compiled bindings#

XAML remains the default way to describe .NET MAUI pages. The most important habit is setting x:DataType on every element where the binding context changes. With it, the XAML compiler turns each binding into strongly typed code that resolves roughly 8 times faster than a reflection-based binding (about 20 times for one-time bindings), and a typo in a property path becomes a build error instead of a silent blank label.

XML
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:FieldTasks.ViewModels"
             xmlns:models="clr-namespace:FieldTasks.Models"
             x:Class="FieldTasks.Views.TaskListPage"
             x:DataType="vm:TaskListViewModel"
             Title="Open tasks">
    <Grid RowDefinitions="Auto,*" Padding="16" RowSpacing="12">
        <HorizontalStackLayout Spacing="8">
            <Entry Text="{Binding NewTitle}" Placeholder="New task" WidthRequest="240" />
            <Button Text="Add" Command="{Binding AddTaskCommand}" />
        </HorizontalStackLayout>

        <RefreshView Grid.Row="1"
                     Command="{Binding LoadCommand}"
                     IsRefreshing="{Binding IsRefreshing}">
            <CollectionView ItemsSource="{Binding Tasks}" SelectionMode="None">
                <CollectionView.ItemTemplate>
                    <DataTemplate x:DataType="models:TaskItem">
                        <Border Padding="12" StrokeShape="RoundRectangle 8">
                            <Border.GestureRecognizers>
                                <TapGestureRecognizer
                                    Command="{Binding Source={RelativeSource
                                        AncestorType={x:Type vm:TaskListViewModel}},
                                        Path=OpenTaskCommand}"
                                    CommandParameter="{Binding .}" />
                            </Border.GestureRecognizers>
                            <Label Text="{Binding Title}" FontSize="16" />
                        </Border>
                    </DataTemplate>
                </CollectionView.ItemTemplate>
            </CollectionView>
        </RefreshView>
    </Grid>
</ContentPage>

.NET 10 adds an opt-in XAML source generator, enabled with the MauiXamlInflator property set to SourceGen, which emits C# for each XAML file at build time and improves tooling. It also introduces a global XML namespace, http://schemas.microsoft.com/dotnet/maui/global, that aggregates your own CLR namespaces through assembly-level XmlnsDefinition attributes, so pages no longer need a stack of xmlns: prefixes.

C# Markup#

Some teams prefer to build UI entirely in C#. Plain C# works, but the CommunityToolkit.Maui.Markup package adds fluent extension methods and lambda-based bindings that keep the code readable. Call UseMauiCommunityToolkitMarkup() on the builder, then write pages like this:

C#
using CommunityToolkit.Maui.Markup;
using FieldTasks.Models;
using FieldTasks.ViewModels;
using static CommunityToolkit.Maui.Markup.GridRowsColumns;

namespace FieldTasks.Views;

public sealed class QuickAddPage : ContentPage
{
    public QuickAddPage(TaskListViewModel viewModel)
    {
        BindingContext = viewModel;

        Content = new Grid
        {
            RowDefinitions = Rows.Define(Auto, Star),
            Padding = 16,
            RowSpacing = 12,
            Children =
            {
                new HorizontalStackLayout
                {
                    Spacing = 8,
                    Children =
                    {
                        new Entry { WidthRequest = 240 }
                            .Placeholder("New task")
                            .Bind(Entry.TextProperty,
                                static (TaskListViewModel vm) => vm.NewTitle,
                                static (TaskListViewModel vm, string text) => vm.NewTitle = text),
                        new Button()
                            .Text("Add")
                            .BindCommand(static (TaskListViewModel vm) => vm.AddTaskCommand)
                    }
                }.Row(0),

                new CollectionView
                {
                    ItemTemplate = new DataTemplate(() =>
                        new Label { Padding = 12 }
                            .Bind(Label.TextProperty, static (TaskItem item) => item.Title))
                }
                .Bind(ItemsView.ItemsSourceProperty, static (TaskListViewModel vm) => vm.Tasks)
                .Row(1)
            }
        };
    }
}

Lambda bindings are compiled bindings, so this style is trim-safe by construction. XAML still has the better designer story, Hot Reload and a larger pool of samples, so choose per team preference rather than per page.

MVVM with CommunityToolkit.Mvvm#

The Model-View-ViewModel pattern fits .NET MAUI's binding engine naturally, and CommunityToolkit.Mvvm removes nearly all of its boilerplate through source generators. Since toolkit 8.4, [ObservableProperty] can annotate partial properties instead of fields. The generator relies on the C# 14 field keyword, so it needs C# 14, which is the default for net10.0 targets; toolkit 8.4.1 and later support it without preview language settings.

C#
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FieldTasks.Models;
using FieldTasks.Services;

namespace FieldTasks.ViewModels;

public partial class TaskListViewModel(ITaskStore store, INavigationService navigation)
    : ObservableObject
{
    public ObservableCollection<TaskItem> Tasks { get; } = [];

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(AddTaskCommand))]
    public partial string NewTitle { get; set; } = string.Empty;

    [ObservableProperty]
    public partial bool IsRefreshing { get; set; }

    [RelayCommand]
    private async Task LoadAsync(CancellationToken cancellationToken)
    {
        try
        {
            var items = await store.GetOpenTasksAsync(cancellationToken);
            Tasks.Clear();
            foreach (var item in items)
            {
                Tasks.Add(item);
            }
        }
        finally
        {
            IsRefreshing = false;
        }
    }

    [RelayCommand(CanExecute = nameof(CanAddTask))]
    private async Task AddTaskAsync(CancellationToken cancellationToken)
    {
        var item = await store.AddAsync(NewTitle.Trim(), cancellationToken);
        Tasks.Insert(0, item);
        NewTitle = string.Empty;
    }

    private bool CanAddTask() => !string.IsNullOrWhiteSpace(NewTitle);

    [RelayCommand]
    private Task OpenTaskAsync(TaskItem item) =>
        navigation.GoToAsync("taskdetail", "Task", item);
}

The generator strips the Async suffix, so LoadAsync becomes LoadCommand. Async commands disable themselves while running by default, which prevents double taps from starting duplicate saves, and a CancellationToken parameter makes the command cancelable. For cross-view-model communication, use WeakReferenceMessenger from the same package: .NET 10 made the old MessagingCenter internal.

Shell Navigation#

Shell gives an app a flyout, tabs and URI-based navigation in one container, and it creates pages on demand, which helps startup time compared with building a TabbedPage eagerly. Top-level pages are declared in AppShell.xaml; detail pages get routes registered in code:

C#
namespace FieldTasks;

public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();
        Routing.RegisterRoute("taskdetail", typeof(TaskDetailPage));
    }
}

public sealed class ShellNavigationService : INavigationService
{
    public Task GoToAsync(string route) => Shell.Current.GoToAsync(route);

    public Task GoToAsync(string route, string key, object value) =>
        Shell.Current.GoToAsync(route, new ShellNavigationQueryParameters { { key, value } });
}

public partial class TaskDetailViewModel : ObservableObject, IQueryAttributable
{
    [ObservableProperty]
    public partial TaskItem? Item { get; set; }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("Task", out var value) && value is TaskItem item)
        {
            Item = item;
        }
    }

    [RelayCommand]
    private Task CloseAsync() => Shell.Current.GoToAsync("..");
}

ShellNavigationQueryParameters carries single-use objects that are cleared after navigation, whereas a plain dictionary stays referenced for the lifetime of the page. Prefer IQueryAttributable over the [QueryProperty] attribute: the attribute relies on reflection and is not safe with full trimming or Native AOT. The page receives its view model through constructor injection, and Shell resolves registered pages from the container. .NET 11 adds route templates such as trip/{tripId} for absolute routes, borrowing the syntax of ASP.NET Core routing.

Platform APIs, Permissions and Native Code#

.NET MAUI bundles the former Xamarin.Essentials APIs: geolocation, sensors, connectivity, secure storage, media picking, text-to-speech, web authentication and more, each behind an interface. Runtime permissions follow one pattern: check the status, request it if needed, and degrade gracefully when the user says no. Declare the permission in the platform manifest too, for example the location permissions in AndroidManifest.xml and NSLocationWhenInUseUsageDescription in Info.plist.

C#
using Microsoft.Extensions.Logging;

namespace FieldTasks.Services;

public sealed class LocationService(IGeolocation geolocation, ILogger<LocationService> logger)
{
    public async Task<Location?> GetCurrentLocationAsync(CancellationToken cancellationToken)
    {
        var status = await Permissions.CheckStatusAsync<Permissions.LocationWhenInUse>();
        if (status != PermissionStatus.Granted)
        {
            status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
        }

        if (status != PermissionStatus.Granted)
        {
            logger.LogInformation("Location permission not granted: {Status}", status);
            return null;
        }

        var request = new GeolocationRequest(GeolocationAccuracy.Medium, TimeSpan.FromSeconds(10));
        return await geolocation.GetLocationAsync(request, cancellationToken);
    }
}

Request permissions after the first page appears, not in MauiProgram or the App constructor. On Android, Permissions.ShouldShowRationale tells you whether the user has already declined once, which is your cue to explain why the app needs access.

When the built-in APIs are not enough, you have three escalating options: partial classes with platform implementations in the Platforms folders, conditional compilation with #if ANDROID and friends, and handler customization. The following mapping selects all text when any Entry gains focus, using each platform's native API:

C#
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("SelectAllOnFocus", (handler, view) =>
{
#if ANDROID
    handler.PlatformView.SetSelectAllOnFocus(true);
#elif IOS || MACCATALYST
    handler.PlatformView.EditingDidBegin += (s, e) =>
        handler.PlatformView.PerformSelector(new ObjCRuntime.Selector("selectAll"), null, 0.0f);
#elif WINDOWS
    handler.PlatformView.GotFocus += (s, e) => handler.PlatformView.SelectAll();
#endif
});

Mapper changes are global, so register them once at startup and check view for a marker subclass when a customization should apply only to some instances.

Performance: Compiled Bindings, Trimming and Startup#

Mobile users notice startup time and app size, and .NET MAUI gives you several levers to improve both.

  • Compiled bindings everywhere. Since .NET 9 the XAML compiler warns (XC0022) about bindings without x:DataType. Treat those warnings as errors in CI. In code, use SetBinding with a lambda, such as label.SetBinding(Label.TextProperty, static (TaskItem t) => t.Title), instead of string paths.
  • Trimming. Release builds on Android and Mac Catalyst, and all iOS device builds, use partial trimming by default. Setting TrimMode to full also trims your own code and dependencies, which requires trim-safe code and zero warnings.
  • Native AOT on Apple platforms. Setting PublishAot for iOS and Mac Catalyst produces packages that Microsoft measured as typically up to 2.5 times smaller, with up to 2 times faster startup on iOS devices for the default template.
  • Lean startup. Keep the application-level resource dictionary small, defer work with lazy initialization, avoid deep layout nesting, prefer Grid over nested stacks, and use Shell so pages are created on demand.
XML
<PropertyGroup>
  <MauiXamlInflator>SourceGen</MauiXamlInflator>
  <MauiEnableXamlCBindingWithSourceCompilation>true</MauiEnableXamlCBindingWithSourceCompilation>
  <WarningsAsErrors>$(WarningsAsErrors);XC0022;XC0023;XC0025</WarningsAsErrors>
  <!-- Run trimming and AOT analyzers for every target -->
  <IsAotCompatible>true</IsAotCompatible>
  <PublishAot Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">
    true
  </PublishAot>
</PropertyGroup>

Do not condition PublishAot or TrimMode on the build configuration: feature switches depend on them, and Debug and Release should behave the same. Also note that dotnet build -t:Publish is not equivalent to dotnet publish when you validate AOT warnings. For the underlying mechanics, read Native AOT and trimming in .NET.

.NET 10 also instruments layout. The Microsoft.Maui ActivitySource and meter record measure and arrange counts and durations (maui.layout.measure_duration and related metrics), so an OpenTelemetry exporter or dotnet-counters can reveal pages with runaway layout passes.

What's New in .NET MAUI 10 (and What .NET 11 Brings)#

Microsoft described the .NET 10 release of .NET MAUI as focused on product quality, but it contains several changes worth planning for:

  • XAML: the opt-in source generator (MauiXamlInflator=SourceGen) and global XML namespaces, with an opt-in implicit default namespace.
  • .NET Aspire integration: a template for a service defaults project, so builder.AddServiceDefaults() wires OpenTelemetry and service discovery into the app, which is handy when the client talks to an Aspire-orchestrated backend. See the .NET Aspire guide.
  • Controls: the optimized CollectionView and CarouselView handlers for iOS and Mac Catalyst, optional in .NET 9, became the default. DatePicker.Date and TimePicker.Time are nullable. SafeAreaEdges gives precise control over notch, bar and keyboard insets.
  • Deprecations: ListView, TableView and the cell types are deprecated in favor of CollectionView. MessagingCenter is internal. DisplayAlert and the animation methods such as FadeTo give way to DisplayAlertAsync and FadeToAsync, and Page.IsBusy is obsolete.
  • Android: API 36 is the default for net10.0-android, JDK 21 is supported, marshal methods are on by default for faster startup, dotnet run can target a device or emulator, and templates now default to a minimum of API 24.
  • iOS and Mac Catalyst: trimmer warnings are enabled by default, and the trimmer runs in more simulator and Mac Catalyst configurations.
  • Web content: HybridWebView and BlazorWebView can intercept web requests through WebResourceRequested.

.NET 11 reached Release Candidate 1 in September 2026 and is due in November. Its .NET MAUI release makes CoreCLR the default runtime on Android and Apple platforms, raises the Android minimum to API 24, enables implicit XAML namespaces by default, stops shipping the Microsoft.Maui.Controls.Compatibility package, and adds device test templates, Shell route templates, a passkeys API and incremental XAML Hot Reload that works with dotnet watch.

Testing and Deploying .NET MAUI Apps#

Most test value comes from plain unit tests of view models and services. Keep view models free of MAUI types, as the INavigationService abstraction above does, and they run in a normal net10.0 xUnit project. If the code must stay in the app project, add net10.0 to its TargetFrameworks and set OutputType to Exe only for the platform targets.

C#
using FieldTasks.ViewModels;

namespace FieldTasks.Tests;

public class TaskListViewModelTests
{
    [Fact]
    public async Task AddTask_trims_title_inserts_item_and_resets_input()
    {
        var store = new InMemoryTaskStore();
        var vm = new TaskListViewModel(store, new FakeNavigation())
        {
            NewTitle = "  Inspect pump 4  "
        };

        await vm.AddTaskCommand.ExecuteAsync(null);

        Assert.Equal("Inspect pump 4", Assert.Single(vm.Tasks).Title);
        Assert.Empty(vm.NewTitle);
        Assert.False(vm.AddTaskCommand.CanExecute(null));
    }
}

For UI automation, Appium drives real devices and emulators through each platform's accessibility layer, so set AutomationId on interactive elements from day one. .NET 11 adds androidtest, iostest and maccatalysttest project templates that run Microsoft.Testing.Platform tests inside an app process on a device or simulator with dotnet test. The unit testing guide covers frameworks and mocking in depth.

Publishing is per platform and should always be scoped to the app project, not the solution:

Bash
# Android: signed AAB and APK (release default is both formats)
dotnet publish src/FieldTasks -f net10.0-android -c Release \
  -p:AndroidKeyStore=true -p:AndroidSigningKeyStore=fieldtasks.keystore \
  -p:AndroidSigningKeyAlias=fieldtasks \
  -p:AndroidSigningKeyPass=env:SIGNING_PASS -p:AndroidSigningStorePass=env:SIGNING_PASS \
  -p:AndroidPackageFormats=apk

# iOS: signed .ipa (run on a Mac)
dotnet publish src/FieldTasks -f net10.0-ios -c Release -p:ArchiveOnBuild=true \
  -p:RuntimeIdentifier=ios-arm64 -p:CodesignKey="Apple Distribution: Contoso Ltd" \
  -p:CodesignProvision="FieldTasks AppStore"

# Windows: MSIX package when WindowsPackageType is Package
dotnet publish src/FieldTasks -f net10.0-windows10.0.19041.0 -c Release \
  -p:RuntimeIdentifierOverride=win-x64

The env: password prefix does not work when producing an AAB, which is why the Android example limits the output to an APK; in CI, pass Play Store secrets through your pipeline's secret store instead. On Windows, new projects debug as unpackaged apps; set WindowsPackageType to Package when you need an MSIX for the Store or enterprise deployment.

Plan upgrades around the support policy. .NET MAUI follows the Microsoft Modern Lifecycle, not the .NET LTS schedule: each major version is supported for at least six months after its successor ships. .NET MAUI 9 support ended on May 12, 2026, and .NET MAUI 10 support ends on May 11, 2027, well before .NET 10 itself. Budget a yearly upgrade.

Best Practices#

  • Target the current major version. Apple and Google tooling moves quickly, and only the latest servicing release of a supported version gets fixes.
  • Put x:DataType everywhere and fail the build on XC0022, XC0023 and XC0025.
  • Use partial properties with the MVVM Toolkit so other generators and analyzers see the generated members.
  • Keep view models platform-agnostic. Wrap navigation, dialogs and device APIs behind interfaces.
  • Prefer CollectionView, Border and Shell over the deprecated ListView, Frame and MessagingCenter.
  • Test on real devices, especially low-end Android hardware, and measure startup in Release builds only.
  • Set AutomationId and semantic properties for accessibility and UI automation at the same time.

Common Pitfalls#

  • Measuring Debug builds. Debug builds use the interpreter or JIT and skip trimming; their startup numbers mean little.
  • Blocking the UI thread. Calling .Result or .Wait() on the main thread freezes the app and can deadlock. Stay async end to end.
  • Updating UI from background threads. Marshal back with MainThread.BeginInvokeOnMainThread or MainThread.InvokeOnMainThreadAsync after work on a background thread.
  • Relying on QueryProperty with full trimming. It uses reflection; implement IQueryAttributable instead.
  • Ignoring trim warnings. A single trim or AOT warning means the published app may fail at runtime.
  • Assuming LTS support. Staying on .NET MAUI 10 until 2028 is not an option under the MAUI support policy.

When to Use .NET MAUI#

Scenario.NET MAUI fitNotes
Line-of-business app for iOS and Android built by a C# teamStrongShared view models, services and most UI code
Consumer app that must feel native on each platformGoodNative controls; budget for per-platform polish
Windows and macOS desktop appGoodWinUI 3 on Windows, Mac Catalyst on macOS
Linux desktop or browser targetsPoorNot supported; consider Avalonia, Uno Platform or Blazor
Pixel-identical custom design systemWeakDrawn-UI frameworks give tighter control
Existing Blazor or web UI to reuse in a native shellStrongHost it with Blazor Hybrid in a BlazorWebView
Windows-only desktop app with heavy data gridsWeakWPF or WinUI 3 have richer desktop ecosystems

Frequently Asked Questions#

Is .NET MAUI production-ready in 2026?#

Yes. .NET MAUI 10 is a mature release, and Microsoft's focus since .NET 9 has been quality, performance and removing legacy APIs rather than new abstractions. Large enterprise apps ship on it, provided teams keep up with the yearly upgrade cadence and test on real devices.

Does .NET MAUI support Linux or WebAssembly?#

No. Official targets are Android, iOS, macOS through Mac Catalyst and Windows, with Tizen support provided by Samsung. For Linux desktops or the browser, consider Avalonia UI, Uno Platform or Blazor, and share view models and services with the .NET MAUI app.

Should I write .NET MAUI UI in XAML or C#?#

Both are first-class. XAML offers Hot Reload, the widest sample base and, from .NET 10, source-generated inflation. C# Markup suits teams that prefer refactoring tools and compile-time checking everywhere. Whichever you choose, use compiled bindings.

How long is .NET MAUI 10 supported?#

Until May 11, 2027. A .NET MAUI major version is supported for at least six months after the next major version ships, so .NET MAUI 10 support ends long before .NET 10 LTS support ends in November 2028. Upgrade to .NET MAUI 11 after it ships in November 2026.

How do I migrate from Xamarin.Forms to .NET MAUI?#

Move to SDK-style projects and a single multi-targeted project, update namespaces, convert custom renderers to handlers or mapper customizations, and replace Xamarin.Essentials calls with the built-in platform APIs. Remove any dependency on the Compatibility package before moving to .NET 11, because it no longer ships there.

Summary#

  • .NET MAUI renders native controls on Android, iOS, macOS and Windows from one C# project, using handlers and property mappers instead of renderers.
  • Use MVVM with CommunityToolkit.Mvvm partial properties, Shell navigation with IQueryAttributable, and interfaces around platform APIs.
  • Compiled bindings, trimming, Native AOT on Apple platforms and lean startup work are the main performance levers.
  • .NET 10 brought XAML source generation, Aspire integration, new default handlers and many deprecations; .NET 11 moves mobile apps to CoreCLR.
  • The support window is short, so plan a yearly upgrade.

Further Reading#