Why migrate, and why now
Microsoft ended support for Xamarin.Forms on May 1, 2024. That means no security patches, no bug fixes, and no OS compatibility updates. Every new iOS and Android release from here on is a potential breakage point you'll have to patch yourself — without any upstream help.
.NET MAUI (Multi-platform App UI) isn't just "Xamarin with a new name." It's a ground-up architectural rebuild on top of .NET 6+, with a single project structure, proper dependency injection, a more capable rendering pipeline, and genuine performance improvements on both iOS and Android. The migration effort is real, but the payoff — maintainability, performance, and access to the modern .NET ecosystem — is worth it.
If your app depends on third-party native SDKs (Swift frameworks or Android AARs), you'll also need to rebuild those bindings for .NET MAUI targets. We cover that in depth in our separate guide: Binding Native iOS and Android SDKs for .NET MAUI.
Area | Xamarin.Forms | .NET MAUI |
Supported until | May 2024 (EOL) | Active development |
Project structure | Multi-project (iOS, Android, shared) | Single project, multi-target |
Minimum .NET | .NET Standard 2.0 | .NET 6+ (MAUI 8 = .NET 8) |
Custom renderers | ViewRenderer | IViewHandler (lighter) |
DI / hosting | Manual / Prism | Built-in MauiAppBuilder |
Hot reload | Limited | Full XAML + C# hot reload |
Startup performance | Slower cold start | Faster startup, less memory |
Desktop support | No | Windows + macOS (Catalyst) |
Note: If you're on Xamarin.iOS / Xamarin.Android (not Forms), the migration path is different and more involved. This guide covers Xamarin.Forms → .NET MAUI specifically.
Before you start: audit your dependencies
The single most important step — and the one most teams skip — is auditing every NuGet package in your solution before touching a line of code. Many Xamarin.Forms-era packages have MAUI equivalents; some don't exist yet; some have been absorbed into .NET MAUI itself.
Run the .NET Upgrade Assistant first
Microsoft's .NET Upgrade Assistant performs a compatibility scan and generates a migration report. Install it and run the analyze command on your solution:
dotnet tool install -g upgrade-assistant upgrade-assistant analyze MySolution.sln --target-tfm-support LTS
The report will flag incompatible packages, deprecated APIs, and platform-specific code that needs manual attention. Treat this as your migration backlog — not a to-do list to ignore.
Common packages: what to do with each
Xamarin.Forms package | MAUI replacement |
Xamarin.Essentials | Built into MAUI as Microsoft.Maui.Essentials |
Xamarin.CommunityToolkit | CommunityToolkit.Maui |
Prism.Forms | Prism.Maui (v8.x) |
ReactiveUI.XamForms | ReactiveUI.Maui |
SkiaSharp.Views.Forms | SkiaSharp.Views.Maui.Controls |
Plugin.Permissions | Microsoft.Maui.Essentials (built-in) |
Xam.Plugin.Media | MediaPicker in Essentials |
CarouselView.FormsPlugin | Built-in CarouselView in MAUI |
Important: Garmin Health SDK, native SDK bindings, and custom native libraries need manual binding projects rebuilt for .NET 8 targets. Budget significant time for these — they cannot be automated.
Project structure changes
Xamarin.Forms used a multi-project structure: a shared PCL or .NET Standard project, plus a platform-specific project for each target (iOS, Android, sometimes UWP). MAUI collapses all of this into a single project with multi-targeting via the <TargetFrameworks> property.
Old structure (Xamarin.Forms)
MySolution/ ├── MyApp/ # Shared .NET Standard project │ ├── App.xaml │ ├── MainPage.xaml │ └── MyApp.csproj ├── MyApp.iOS/ # iOS head project │ ├── AppDelegate.cs │ └── MyApp.iOS.csproj └── MyApp.Android/ # Android head project ├── MainActivity.cs └── MyApp.Android.csproj
New structure (.NET MAUI)
MySolution/ └── MyApp/ # Single project, multi-targeted ├── Platforms/ │ ├── Android/ │ │ └── MainActivity.cs │ ├── iOS/ │ │ └── AppDelegate.cs │ ├── MacCatalyst/ │ └── Windows/ ├── Resources/ # Unified assets: fonts, images, raw ├── App.xaml ├── MauiProgram.cs # New entry point └── MyApp.csproj
The new .csproj format
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFrameworks>net8.0-android;net8.0-ios;net8.0-maccatalyst</TargetFrameworks> <UseMaui>true</UseMaui> <RootNamespace>MyApp</RootNamespace> <ApplicationId>com.yourcompany.myapp</ApplicationId> <ApplicationVersion>1</ApplicationVersion> <ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion> </PropertyGroup> </Project>
Step-by-step migration walkthrough
Step 1 — Create a new .NET MAUI project alongside your existing solution
Don't try to convert your existing Xamarin project in-place. Create a fresh MAUI project, then migrate code into it incrementally. This keeps your existing app shippable throughout the migration.
dotnet new maui -n MyApp.Maui
Step 2 — Update namespaces throughout your shared code
The most mechanical part of the migration. The Xamarin.Forms namespace becomes Microsoft.Maui and Microsoft.Maui.Controls. Run a global find-and-replace across your shared project:
Xamarin.Forms → Microsoft.Maui.Controls Xamarin.Forms.Xaml → Microsoft.Maui.Controls.Xaml Xamarin.Essentials → Microsoft.Maui.ApplicationModel Xamarin.Forms.PlatformConfiguration → Microsoft.Maui.Controls.PlatformConfiguration
In XAML files, update the xmlns declarations:
<!-- Before (Xamarin.Forms) --> <ContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"> <!-- After (.NET MAUI) --> <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
Step 3 — Replace App.xaml.cs startup with MauiProgram.cs
MAUI uses a generic host model similar to ASP.NET Core. Your startup logic, service registration, and app configuration all move into MauiProgram.cs:
public static class MauiProgram { public static MauiApp CreateMauiApp() { var builder = MauiApp.CreateBuilder(); builder .UseMauiApp<App>() .ConfigureFonts(fonts => { fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); }) .UseMauiCommunityToolkit(); // Register services builder.Services.AddSingleton<IMyService, MyService>(); builder.Services.AddTransient<MainViewModel>(); builder.Services.AddTransient<MainPage>(); return builder.Build(); } }
Step 4 — Move platform-specific code into the Platforms/ folders
Any code that previously lived in your MyApp.iOS or MyApp.Android projects now lives in Platforms/iOS/ and Platforms/Android/ within the single project. The build system automatically includes only the relevant platform code based on the current build target.
Step 5 — Migrate resources to the unified Resources/ folder
MAUI unifies asset management. Images no longer need to live in platform-specific drawable-hdpi / drawable-xhdpi folders on Android and @2x / @3x on iOS. Drop a single SVG or high-resolution PNG into Resources/Images/ and MAUI handles resizing automatically via MSBuild.
<ItemGroup> <MauiImage Include="Resources\Images\logo.svg" BaseSize="120,40" /> </ItemGroup>
Renderers → Handlers: the hardest part
If your app uses custom renderers, this section will take the most time. Xamarin.Forms used ViewRenderer<TView, TNativeView> to bridge controls to native views. MAUI replaces this with a lighter, interface-based Handler architecture.
The key difference: Handlers use a mapper pattern instead of overridable methods, which makes partial customisations much cleaner.
Old renderer pattern
[assembly: ExportRenderer(typeof(MyCustomEntry), typeof(MyCustomEntryRenderer))] namespace MyApp.Android { public class MyCustomEntryRenderer : EntryRenderer { protected override void OnElementChanged( ElementChangedEventArgs<Entry> e) { base.OnElementChanged(e); if (Control != null) Control.Background = Color.Transparent; } } }
New Handler pattern
// 1. Define the handler class public partial class MyCustomEntryHandler : EntryHandler { public static IPropertyMapper<IEntry, EntryHandler> MyMapper = new PropertyMapper<IEntry, EntryHandler>(Mapper); public MyCustomEntryHandler() : base(MyMapper) { } } // 2. Platform-specific implementation in Platforms/Android/ public partial class MyCustomEntryHandler { protected override AppCompatEditText CreatePlatformView() { var view = base.CreatePlatformView(); view.Background = null; return view; } } // 3. Register in MauiProgram.cs builder.ConfigureMauiHandlers(handlers => { handlers.AddHandler(typeof(MyCustomEntry), typeof(MyCustomEntryHandler)); });
Tip: For apps with many renderers, prioritise migrating the ones used on high-traffic screens first. A renderer that's only used on a settings page can wait — ship the migration in layers.
Plugin replacements
Many Xamarin plugins have been superseded by built-in MAUI Essentials APIs. Here are the API-level changes that catch most developers:
Permissions
// Xamarin.Essentials var status = await Permissions.RequestAsync<Permissions.Camera>(); // .NET MAUI Essentials — identical API, different namespace using Microsoft.Maui.ApplicationModel; var status = await Permissions.RequestAsync<Permissions.Camera>();
Connectivity
// Xamarin.Essentials var connected = Connectivity.NetworkAccess == NetworkAccess.Internet; // .NET MAUI — inject IConnectivity instead (testable) public class MyViewModel { private readonly IConnectivity _connectivity; public MyViewModel(IConnectivity connectivity) => _connectivity = connectivity; public bool IsOnline => _connectivity.NetworkAccess == NetworkAccess.Internet; }
SecureStorage
// Same API in both — just update the namespace await SecureStorage.SetAsync("oauth_token", token); var token = await SecureStorage.GetAsync("oauth_token");
Platform-specific code: the Device class is gone
In Xamarin.Forms, platform checks were done via Device.RuntimePlatform. In MAUI, use DeviceInfo.Current.Platform or — better — conditional compilation with #if ANDROID / #if IOS.
// Xamarin.Forms — don't use this in MAUI if (Device.RuntimePlatform == Device.iOS) { ... } // .NET MAUI — runtime check if (DeviceInfo.Current.Platform == DevicePlatform.iOS) { ... } // .NET MAUI — compile-time check (preferred) #if IOS // iOS-only code #elif ANDROID // Android-only code #endif
The Device.BeginInvokeOnMainThread call is also gone. Replace it with:
// Xamarin.Forms Device.BeginInvokeOnMainThread(() => { ... }); // .NET MAUI MainThread.BeginInvokeOnMainThread(() => { ... }); // Or — async version in MAUI 7+ await MainThread.InvokeOnMainThreadAsync(() => { ... });
Testing and CI/CD
MAUI apps can be unit tested with standard xUnit / NUnit — the key is to isolate your ViewModels and services from MAUI's UI layer. Don't try to spin up a MAUI app in your CI environment; test the logic, not the rendering.
Unit testing ViewModels
public class MainViewModelTests { [Fact] public async Task LoadItems_ShouldPopulateItems_WhenServiceSucceeds() { var mockService = new Mock<IItemService>(); mockService.Setup(s => s.GetItemsAsync()) .ReturnsAsync(new List<Item> { new() { Name = "Test" } }); var vm = new MainViewModel(mockService.Object); await vm.LoadItemsAsync(); Assert.Single(vm.Items); } }
GitHub Actions: build and sign for Android
name: MAUI Build on: push: branches: [main] jobs: android: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: '8.x' - name: Install MAUI workload run: dotnet workload install maui-android - name: Restore run: dotnet restore MyApp/MyApp.csproj - name: Build AAB run: | dotnet publish MyApp/MyApp.csproj \ -f net8.0-android \ -c Release \ /p:AndroidPackageFormat=aab \ /p:AndroidSigningKeyStore=keystore.jks \ /p:AndroidSigningKeyAlias=${{ secrets.KEY_ALIAS }} \ /p:AndroidSigningKeyPass=${{ secrets.KEY_PASS }} \ /p:AndroidSigningStorePass=${{ secrets.STORE_PASS }}
AAB signing gotcha: If you see a multiple certificate chains error, your keystore has more than one certificate entry. Use keytool -list -v -keystore keystore.jks to inspect it and explicitly pass the correct alias via AndroidSigningKeyAlias.
Common pitfalls (and how to avoid them)
1. XAML Hot Reload stops working mid-migration
Hot Reload requires the app to be running in debug mode with a valid MAUI project structure. If you've partially migrated XAML and have mixed namespace declarations, Hot Reload silently fails. Keep your XAML namespace declarations consistent — even one file with old Xamarin xmlns will break reload for the whole project.
2. Shell navigation breaks
If you were using Xamarin.Forms Shell, the route registration API is mostly the same, but Shell's implicit routes have changed. Any page you want to navigate to must be explicitly registered in MauiProgram.cs if it's not already in the ShellContent hierarchy.
3. Fonts not loading on iOS
MAUI requires fonts to be declared in both MauiProgram.cs (via ConfigureFonts) and as a MauiFont item in the .csproj. Missing either declaration causes silent fallback to the system font with no build error.
<MauiFont Include="Resources\Fonts\*.ttf" />
4. CollectionView performance regression
MAUI's CollectionView uses a different virtualization strategy than Xamarin.Forms. If you have lists with complex item templates, set ItemSizingStrategy="MeasureAllItems" only when necessary — it forces measurement of every item and destroys scroll performance on long lists.
5. Android back button handling
Xamarin had OnBackPressed() in MainActivity. In .NET MAUI 8+, handle back navigation via BackButtonBehavior in XAML or override OnNavigatedFrom in your page. The Activity-level override still works but is discouraged.
Realistic timelines by app size
Based on migrations we've run, here's an honest breakdown. "Size" refers to the number of distinct screens, not lines of code.
App size | Screens | Custom renderers | Realistic timeline |
Small | 5–15 | 0–2 | 3–7 days |
Medium | 15–40 | 2–6 | 2–5 weeks |
Large | 40+ | 6+ | 2–4 months |
Large + native SDK bindings | 40+ | 6+ | 3–6 months |
These timelines assume one experienced MAUI developer working full-time, a codebase that's reasonably well-structured, and no major architectural changes during migration. If you're also upgrading your architecture — adding proper DI, replacing a legacy nav stack, refactoring into MVVM — add 30–50% to the estimate.
Ship in phases. The best migrations we've run were phased: migrate the project structure and shared code first, get a working build, ship it. Then migrate custom renderers one by one. Then tackle CI/CD. Never try to do everything in one branch — it leads to months-long PRs that are impossible to review.
Need someone to do this for you? We've completed 10+ production Xamarin → MAUI migrations.
Final thoughts
Migrating from Xamarin.Forms to .NET MAUI is a significant investment, but it's not optional — Xamarin is dead, and every month you delay is another month of accumulating compatibility debt. The good news: MAUI is genuinely better. The single-project structure, handler architecture, and .NET 8 performance improvements are real improvements, not just a rebrand.
The migration path is well-documented and the tooling has matured considerably since MAUI's rocky .NET 6 launch. On .NET 8, it's stable, performant, and a pleasure to work in.
If you're facing a large codebase, complex native integrations, or simply don't have the internal bandwidth to handle the migration without risking your release schedule, we've done this before — get in touch.
Related reading
If your migration involves third-party native SDKs that only ship as Swift frameworks or Android AARs — think wearable health devices, payments providers, or biometric auth libraries — the next challenge is binding them into .NET MAUI. We've published a full production guide covering Swift-to-Objective-C wrappers, XCFramework builds, Android AAR binding, and Metadata.xml transformations:
Binding Native iOS and Android SDKs for .NET MAUI: A Production Engineering Guide →
If you'd rather hand the migration off entirely, our Xamarin to .NET MAUI Migration Services page covers our methodology, the ten technical challenges we solve on every engagement, and how pricing is structured.


