Documentation

Put a .NET MAUI app on the web

Everything you need to run your app with MauiWeb: setup, options, the web-only features, and how to go live.

Overview #

MauiWeb runs your .NET MAUI app on an ASP.NET Core server and sends the browser real HTML. Every browser tab gets its own instance of your app, and MauiWeb's handlers turn each control into HTML.

The first request returns the finished page. After that, a WebSocket carries the visitor's taps and typing to your app and brings back only the parts of the page that changed. There is no WebAssembly to download and no splash screen.

Want to see it before you read? The live gallery runs every page of the Control Gallery sample on MauiWeb.

How it works #

Every visitor gets their own instance of your MAUI app on the server. MauiWeb's handlers turn each control into HTML, and a small script in the page keeps the two in step.

1

First request: a finished page

The server builds your MAUI app for the visitor, renders the first page and returns complete HTML. Search engines and slow phones see content at once.

GET /products → 200 text/html
2

Then a WebSocket

The page connects back and reports its viewport, theme, language and saved preferences, so the app lays out for the real screen.

/_maui/ws · hello
3

Taps run your C#

Events reach your handlers, commands and bindings on the server. Whatever they change is sent back as a small batch of DOM changes.

click → Clicked() → 3 DOM changes

Requirements #

  • .NET 11 SDK and .NET MAUI 11. MauiWeb is built and tested on MAUI 11 release candidate 2; .NET 9 and 10 are not supported.
  • Your app's pages, view models and services in a project that can target plain net11.0, next to its phone targets.
  • A host that runs ASP.NET Core and allows WebSockets. Linux, Windows and macOS all work.

Add a web project #

Create an ASP.NET Core project beside your app. It references your shared MAUI project and the MauiWeb package.

Terminal
dotnet new web -n MyApp.Web
cd MyApp.Web
dotnet add package MauiWeb
dotnet add reference ../MyApp/MyApp.csproj

Then map MauiWeb in Program.cs. UseMauiWeb() replaces the native handlers with browser handlers; handlers you register after it still win, so your own controls can override any of them.

Program.cs
using MauiWeb.Hosting;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapMauiWeb(() => MauiProgram.CreateMauiApp(b => b.UseMauiWeb()), options =>
{
    options.Title = "My App";
    options.LicenseKey = builder.Configuration["MauiWeb:LicenseKey"];
});

app.Run();

Share your app #

Give CreateMauiApp a hook for the platform setup. Your phone heads call it as before; the web head passes UseMauiWeb().

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

        platform?.Invoke(builder);
        builder.Services.AddTransient<MainViewModel>();
        return builder.Build();
    }
}

Fonts, images and raw files are served from the web project's Resources folder. Copy them from the shared project at build time:

MyApp.Web.csproj
<ItemGroup>
  <None Include="..\MyApp\Resources\**" CopyToOutputDirectory="PreserveNewest"
        Link="Resources\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>

Run it #

Terminal
dotnet run --urls http://localhost:5280

Open http://localhost:5280. Every tab you open gets its own copy of your app. Reloading a tab, or a short network drop, reconnects to the same session for 45 seconds.

On localhost, private addresses and *.local, *.test, *.localhost and *.internal hosts, no license key is needed.

Options #

Set these in the second argument of MapMauiWeb.

OptionDefaultWhat it does
Title""Page title until your app sets one.
Theme"web"Control look: "web" (shadcn/ui style) or "ios" (iOS 26 glass style).
AlertTheme"ios"Look of DisplayAlert, prompts and action sheets: "ios", or "web" dialogs with the web theme.
TabBarTheme"ios"The floating glass tab bar, or "web" for a full-width bar.
ColorSchemenull"light" or "dark" to fix the scheme; null follows the visitor's system.
ThemeColor"#ffffff"Color of the browser's own interface.
WebAppCapabletrueAdded to an iPhone or iPad home screen, the site opens like an app, without browser bars.
StatusBarStylenulliOS status bar of the home-screen app; "black-translucent" draws under it.
HeadHtmlnullExtra HTML at the end of <head>, such as analytics or meta tags.
ResourceRootsResources/…, wwwrootFolders searched for images, fonts and raw files.
DisconnectedTimeout45 sHow long a session waits for its tab to reconnect.
MaxSessionsnullMost sessions at once; extra visitors get a "busy" page. Closed tabs free their place.
IsolationNoneSessionIsolation.Process runs every session in its own process. See below.
WarmProcesses1With process isolation, processes kept started so visitors don't wait for one.
LicenseKeynullYour MauiWeb key. Falls back to the MAUIWEB_LICENSE_KEY environment variable.
LicenseNoticeInterval5 minHow soon the "Unlicensed MauiWeb" notice returns after it is closed.

Session isolation #

Every visitor always gets their own MauiApp: its own DI container, Application, windows and pages. What visitors can share is the process, and with it any static fields.

  • None (default). All sessions share one process and one UI thread, like the windows of a desktop app. Fast and light. Use it when per-user state lives in pages, view models and scoped services.
  • Process. Each session runs in its own copy of your app's process, so even static fields belong to one visitor. Use it for code written with a single user in mind, as phone apps often are.
Program.cs
app.MapMauiWeb(() => MauiProgram.CreateMauiApp(b => b.UseMauiWeb()), options =>
{
    options.Isolation = SessionIsolation.Process;
    options.WarmProcesses = 2;   // started ahead, so nobody waits for a boot
    options.MaxSessions = 50;    // most processes at once
});

Themes #

Controls come in two looks. "web" follows the shadcn/ui style that fits a website. "ios" follows iOS 26, with glass bars, for apps that should feel the same as on an iPhone. Both have light and dark versions and follow the visitor's system setting unless you set ColorScheme.

Compare them in the live gallery with the theme switch above the app.

Page addresses #

Give a page a web address when you register it with dependency injection. The address shows in the browser when the page is shown, works with the Back button, and opens the same page when someone shares or reloads the link, already in the first HTML, so search engines see it too.

MauiProgram.cs
using MauiWeb.Navigation;

builder.Services.AddWebPage<ShopPage>("shop");
builder.Services.AddWebPage<ProductPage, ProductViewModel>("shop/{id}/{slug?}");
builder.Services.AddWebPage<CartPage>("store/cart");          // a tab of a TabbedPage
builder.Services.AddWebPage<SettingsPage>("account/settings"); // a FlyoutPage section

The values in the address reach the view model the same way Shell passes query parameters, through IQueryAttributable. The view model gives its values back through IWebRouteValues, so the address follows what the page shows. A {slug} it doesn't supply is made from the page title.

ProductViewModel.cs
public class ProductViewModel : IQueryAttributable, IWebRouteValues
{
    public int Id { get; private set; }

    // Address to page: "/shop/42" gives id = "42".
    public void ApplyQueryAttributes(IDictionary<string, object> query) =>
        Id = int.Parse((string)query["id"]);

    // Page to address: shown as "/shop/42/red-shoes" (the slug comes from the title).
    public IReadOnlyDictionary<string, object?> RouteValues =>
        new Dictionary<string, object?> { ["id"] = Id };
}
  • Navigation stacks: pushing a registered page, with PushAsync or await WebNavigation.GoToAsync("shop/42"), shows its address. A link to /shop/42 opens the product with the shop below it, so Back stays in your app.
  • Tabs: each registered tab page has its own address, and a link to it opens that tab. Switching tabs replaces the address instead of adding Back steps.
  • Flyout: registered sections without values (account/settings) open as the detail; pages with values are pushed inside the section.
  • Pages you don't register still get an address made from their title, but a link can't rebuild them.
  • Registration uses generic types only, so it is safe for NativeAOT and trimming. On iOS and Android, AddWebPage is an ordinary DI registration.

See it live: stack, tabs, flyout.

Web platform-specifics #

Like Android's toolbar placement, On<Web>() sets options that only apply in the browser. The tab bar of a TabbedPage or Shell can sit where a website needs it.

C#
using MauiWeb.PlatformConfiguration;
using MauiWeb.PlatformConfiguration.WebSpecific;

tabs.On<Web>().SetTabBarPlacement(WebTabBarPlacement.Navbar);
shell.On<Web>().SetTabBarPlacement(WebTabBarPlacement.Left);
PlacementResultTry it
DefaultA bottom bar on narrow screens, like a phone app./tabs
Bottom, TopThe bar fixed at the bottom or top./tabstop
Left, RightA vertical rail on the leading or trailing side (flips for right-to-left)./tabsleft
NavbarA landing-page navbar: the page title as the brand, tabs as links, a menu button on phones./tabsnavbar

HtmlView #

HtmlView puts raw HTML or a web component inside a MAUI layout. In the browser it is real inline HTML; on Android, iOS, Mac Catalyst and Windows the same HTML shows in a WebView that sizes itself to its content. DOM events you name are reported to C# on every platform.

C#
using MauiWeb.Controls;

var view = new HtmlView
{
    Html = "<button id=\"buy\">Buy now</button>",
    Events = "click",
};
view.EventRaised += (s, e) =>
{
    if (e.Name == "click" && e.TargetId == "buy")
        Checkout();
};
The HTML is not sanitized. Never put text from users into Html without encoding it first.

Essentials #

MAUI Essentials APIs work through the visitor's browser, where the browser offers the feature and the visitor allows it. Preferences are saved in the visitor's browser and sent with every new session, so they survive reloads.

PreferencesSecureStorageClipboardShareLauncherBrowserEmailSmsPhoneDialerMapGeolocationGeocodingConnectivityBatteryDeviceInfoDeviceDisplayAppInfoVersionTrackingFileSystemFilePickerMediaPickerScreenshotTextToSpeechVibrationHapticFeedbackFlashlightAccelerometerGyroscopeMagnetometerBarometerCompassOrientationSensorContactsPermissionsWebAuthenticatorSemanticScreenReader

For WebAuthenticator, map the callback page that ends the sign-in: app.MapMauiWebAuthCallback(); (served at /_maui/auth-callback).

Add-ons #

.NET MAUI Community Toolkit

Package MauiWeb.Toolkit. Adds Popup, MediaElement, TouchBehavior, DrawingView, Snackbar, Toast and FileSaver.

SkiaSharp

Package MauiWeb.SkiaSharp. SKCanvasView and SKGLView are drawn on the server and sent as frames. Choose lossless WebP, PNG or lossy frames, the quality, and a frame-rate limit.

Maps

Package MauiWeb.Maps. The Map control uses Leaflet with OpenStreetMap tiles by default, or Google Maps when you set a key. Restrict a Google key to your site's address in the Google Cloud console: the browser receives it.

Program.cs
app.MapMauiWeb(() => MauiProgram.CreateMauiApp(b => b
    .UseMauiWeb()
    .UseMauiWebToolkit()
    .UseMauiWebSkiaSharp(skia => skia.MaxFps = 30)
    .UseMauiWebMaps(maps => maps.GoogleMapsApiKey = builder.Configuration["Maps:Key"])));

Custom handlers #

A handler builds a server-side DOM element for your control. Derive from WebViewHandler<TView, DomElement>: size, margin, background, transforms, visibility, alignment, semantics, shadow and clip are already handled.

RatingHandler.cs
using MauiWeb.Dom;
using MauiWeb.Handlers;

public class RatingHandler : WebViewHandler<RatingView, DomElement>
{
    public static readonly PropertyMapper<RatingView, RatingHandler> Mapper =
        new(WebViewHandler.ViewMapper)
        {
            [nameof(RatingView.Value)] = (h, v) => h.PlatformView.SetAttribute("data-value", v.Value.ToString()),
        };

    public RatingHandler() : base(Mapper) { }

    protected override DomElement CreatePlatformView() => new DomElement("div", "rating");

    // Each tap adds a star, then starts again after five.
    protected override void ConnectHandler(DomElement element) =>
        element.On("click", _ => VirtualView.Value = VirtualView.Value % 5 + 1);
}

Register it after UseMauiWeb(): builder.ConfigureMauiHandlers(h => h.AddHandler<RatingView, RatingHandler>()). The element API includes SetAttribute, SetStyle, AddClass, SetProperty, On for events and Document.InvokeAsync to call your own browser script.

Deploying #

A MauiWeb site is an ordinary ASP.NET Core app. Publish it as a single native binary with NativeAOT, so no .NET assemblies sit on the server.

Terminal
dotnet publish -c Release -r linux-x64 -p:PublishAot=true
  • Allow WebSockets on your proxy or platform, for /_maui/ws.
  • Use sticky sessions when you run more than one instance: a visitor's socket must reach the instance that holds their app.
  • Health check: GET /_maui/health answers ok sessions=….
  • Behind a proxy, forward the original host and scheme so the license check sees your real domain.
Building with AppMare? It publishes your app as a MauiWeb site to your own Google Cloud Run or Azure App Service, license included.

License key #

A MauiWeb key lists the domains it covers, such as shop.example.com and www.shop.example.com. It is checked on your server, inside your app, so pages never wait for a license server.

Terminal
export MAUIWEB_LICENSE_KEY="MW1.…"

On a live domain the key does not cover, visitors see an "Unlicensed MauiWeb" notice when the page opens, and again every few minutes after they close it. The site keeps working. The notice is drawn by the server, like any alert.

About once a day, in the background, your server tells our license service which licensed domain it runs on: the key's id, the domain and the MauiWeb version, nothing else. If the service can't be reached, nothing changes for a week; after that, visitors see a notice asking the site owner to let the server reach the internet, and the site keeps working. A domain you remove from your license, or a revoked key, shows the "Unlicensed MauiWeb" notice at the next check. To use a different address for the check, set MauiWebOptions.LicenseCheckUrl or MAUIWEB_LICENSE_CHECK_URL.

A key can include an "updates until" date: it keeps working with every MauiWeb release published before that date. See licensing and pricing.

Performance #

Measured on 25 October 2026 with the gallery's /basics page (about 60 controls), Debug build, server and load generator on one Apple Silicon Mac.

Visitors at onceMemory per visitorFirst response p50 / p95Tap round trip p50 / p95
100~1.3 MB127 / 337 ms1.8 / 2.6 ms
1,000~0.45–0.6 MB131 / 225 ms13.8 / 40 ms
  • Each new visitor costs about 10 ms of UI-thread time, so one process starts around 100 visitors a second. For more, run several processes behind a load balancer with sticky sessions.
  • Bigger pages and more app state use more memory. Release builds do better than these numbers.