Skip to content

Instantly share code, notes, and snippets.

@mattleibow
Created May 7, 2026 17:25
Show Gist options
  • Select an option

  • Save mattleibow/c140ad06bf1be77b38bf1f7e175b9e9d to your computer and use it in GitHub Desktop.

Select an option

Save mattleibow/c140ad06bf1be77b38bf1f7e175b9e9d to your computer and use it in GitHub Desktop.
The Perfect Navigation System for .NET MAUI — cross-platform comparison and ground-up design

The Perfect Navigation System for .NET MAUI

What Would It Look Like If We Started From Scratch?

This document compares navigation systems across platforms and frameworks, identifies what each gets right and wrong, and proposes what a ground-up replacement for MAUI navigation would look like. The goal is to design the ideal system first, then layer backward compatibility on top — not the other way around.


Part 1: The Landscape Today

SwiftUI (NavigationStack + NavigationPath)

Model: Type-safe, value-driven navigation. The navigation stack is a @State array of Hashable values. You push by appending a value; the framework resolves which view to show via navigationDestination(for:).

enum Route: Hashable {
    case productDetail(sku: String)
    case review(sku: String, stars: Int)
    case settings
}

@State private var path = NavigationPath()

NavigationStack(path: $path) {
    ProductCatalog()
        .navigationDestination(for: Route.self) { route in
            switch route {
            case .productDetail(let sku): ProductDetailView(sku: sku)
            case .review(let sku, let stars): ReviewView(sku: sku, stars: stars)
            case .settings: SettingsView()
            }
        }
}

// Navigate programmatically
path.append(Route.productDetail(sku: "seed-tomato"))

// Pop to root
path = NavigationPath()

// Deep link: just set the whole path
path = NavigationPath([Route.productDetail(sku: "seed-tomato"), Route.review(sku: "seed-tomato", stars: 5)])
✅ Gets Right ❌ Gets Wrong
Navigation state is just data — serialize it, restore it, test it TabView tabs are still structural, not part of NavigationPath
Type-safe: compiler catches invalid routes No built-in ViewModel separation (View IS the ViewModel in SwiftUI)
Deep linking = just setting the path array Modal presentation is a separate .sheet() modifier, not unified
Pop/reset = trivial array operations No route constraints or parameter validation
Each destination gets its own strongly-typed parameters No navigation result/failure reporting

Key insight: Navigation state as a simple value type. Push = append. Pop = remove. Deep link = set. Everything is testable without a UI.


Android Jetpack Compose Navigation

Model: String-route based with type-safe wrappers. Routes are URI patterns ("profile/{userId}"). Navigation is imperative via NavController. The community library Compose Destinations adds compile-time type safety via KSP code generation.

// Official: string routes (like Shell today)
NavHost(navController, startDestination = "home") {
    composable("home") { HomeScreen() }
    composable("product/{sku}") { backStackEntry ->
        val sku = backStackEntry.arguments?.getString("sku")
        ProductScreen(sku = sku!!)
    }
}
navController.navigate("product/seed-tomato")

// Compose Destinations: type-safe (community library)
@Destination
@Composable
fun ProductScreen(sku: String)

navController.navigate(ProductScreenDestination(sku = "seed-tomato"))
✅ Gets Right ❌ Gets Wrong
Route templates with {param} (sound familiar?) Official API is stringly-typed; type safety requires a third-party library
NavGraph groups routes into scopes Parameters are untyped Bundle values under the hood
Deep linking maps directly to routes No built-in ViewModel parameter injection (use SavedStateHandle workaround)
Back stack is explicit and inspectable Bottom navigation tabs are structural XML, not navigable routes
Nested NavGraphs for feature modules Modal/dialog routes are a separate dialog() DSL, not unified

Key insight: Route templates are the right URI model, but they need compile-time type safety on top. The community solved this with code generation (Compose Destinations) — the official API never caught up.


WinUI 3 (Frame + NavigationView)

Model: Classic page-based Frame navigation with a NavigationView chrome. Navigation is entirely imperative (Frame.Navigate(typeof(Page), param)). No routing system at all — you build your own.

// Navigate by type
ContentFrame.Navigate(typeof(ProductPage), new ProductArgs { Sku = "seed-tomato" });

// NavigationView is just chrome — you wire item clicks to Frame.Navigate manually
private void OnItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args)
{
    var tag = (args.InvokedItemContainer as NavigationViewItem)?.Tag as string;
    if (tag == "products") ContentFrame.Navigate(typeof(ProductCatalogPage));
}
✅ Gets Right ❌ Gets Wrong
Simple mental model: Frame has a stack, navigate by type No routing system — every app reinvents the wheel
Parameters are strongly typed (any object) No URI-based navigation, no deep linking
NavigationView is just chrome, not coupled to navigation No ViewModel lifecycle — Page code-behind only
Back/forward stack is explicit No tab/modal abstraction
DI-friendly — you create pages however you want No navigation result/failure API

Key insight: The simplest possible model (navigate by type with a parameter object) is actually very powerful. The problem is everything WinUI doesn't provide — routing, deep linking, tabs, modals, ViewModel lifecycle.


Prism.Maui

Model: URI-based navigation with a centralized INavigationService. Everything — stacks, tabs, modals — is expressed as a navigation URI. DI-first: pages and ViewModels are registered in a container and auto-wired.

// Registration (one place for everything)
containerRegistry.RegisterForNavigation<ProductCatalogPage, ProductCatalogViewModel>();
containerRegistry.RegisterForNavigation<ProductDetailPage, ProductDetailViewModel>();
containerRegistry.RegisterForNavigation<ReviewPage, ReviewViewModel>();

// Push
await _navigationService.NavigateAsync("ProductDetailPage", new NavigationParameters
{
    { "sku", "seed-tomato" }
});

// Create tabs programmatically
await _navigationService.NavigateAsync("/MainTabbedPage?createTab=Products|ProductCatalog&createTab=Orders|OrderList");

// Modal
await _navigationService.NavigateAsync("ReviewPage", useModalNavigation: true);

// ViewModel receives parameters
public class ProductDetailViewModel : INavigationAware
{
    public void OnNavigatedTo(INavigationParameters parameters)
    {
        Sku = parameters.GetValue<string>("sku");
    }

    public void OnNavigatingFrom(INavigationParameters parameters) { }
}

// Navigation guard
public class EditViewModel : IConfirmNavigation
{
    public bool CanNavigate(INavigationParameters parameters)
    {
        return !HasUnsavedChanges || ConfirmDiscard();
    }
}
✅ Gets Right ❌ Gets Wrong
ONE API for everything: push, tabs, modals URI syntax for tabs is arcane (`?createTab=Name
Navigation result returned (INavigationResult) Still stringly-typed URIs, no compile-time safety
Full ViewModel lifecycle (INavigationAware, IConfirmNavigation, IDestructible) Heavy framework — lots of abstractions to learn
DI-first: pages created by container Parameter passing is dictionary-based, not strongly typed
Works across all presentation modes uniformly Relies on its own container abstraction, not just IServiceProvider

Key insight: Prism proves that one API for all navigation operations is achievable and desirable. Its weakness is that it's still stringly-typed and dictionary-based for parameters.


Expo Router / React Navigation 7

Model: File-system-based routing with type-safe TypeScript support. Directory structure IS the route structure. Tabs, stacks, and modals are all expressed as layout files.

app/
  _layout.tsx          → Stack (root)
  index.tsx            → "/" (home)
  (tabs)/
    _layout.tsx        → Tabs
    products.tsx       → "/products"
    orders.tsx         → "/orders"
  product/[sku].tsx    → "/product/:sku" (dynamic route)
  modal.tsx            → "/modal" (presented as modal via layout config)
// Navigate with type safety
router.push({ pathname: '/product/[sku]', params: { sku: 'seed-tomato' } });

// Modal is just a route with presentation config
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />

// Tabs are just a directory with a layout
<Tabs>
  <Tabs.Screen name="products" />
  <Tabs.Screen name="orders" />
</Tabs>
✅ Gets Right ❌ Gets Wrong
Structure IS the routing — no registration step File-system routing doesn't map to .NET compilation model
Type-safe parameters via TypeScript generics No ViewModel concept (component IS the view+logic)
Tabs, modals, stacks all unified under one layout system Deep nesting can get confusing
Deep linking is automatic — URL = file path No navigation guards (use redirect hooks)
Each route gets its own params via useLocalSearchParams Web-centric design doesn't always fit mobile patterns

Key insight: The layout-based approach — where tabs, modals, and stacks are all just different layout containers for the same route system — is the cleanest unification anyone has shipped.


Blazor

Model: Attribute-based routing on components with compile-time route templates. Parameters are strongly typed via [Parameter] properties. Navigation is URI-based via NavigationManager.

@page "/product/{Sku}"
@page "/product/{Sku}/review/{Stars:int}"

<h1>@Sku</h1>

@code {
    [Parameter] public string Sku { get; set; }
    [Parameter] public int Stars { get; set; } = 5;  // default value
}

// Navigate
NavigationManager.NavigateTo("/product/seed-tomato/review/3");
✅ Gets Right ❌ Gets Wrong
Route templates with constraints ({Stars:int}) — the inspiration for our PR No modal/tab concept (web paradigm)
Compile-time parameter binding via [Parameter] No navigation result API
Multiple route templates per component (@page x N) No back stack management (browser handles it)
Default values on parameters No ViewModel separation (component IS everything)
Cascading parameters for inherited values No navigation guards (use OnNavigatedTo workaround)

Key insight: [Parameter] with compile-time binding is the gold standard for parameter delivery. No dictionaries, no string keys, no casting.


Part 2: What Each Framework Gets Right (Synthesis)

Capability Best Implementation Why
Navigation state as data SwiftUI NavigationPath Push/pop/deep-link = array operations. Serializable. Testable.
One API for everything Prism INavigationService Push, tabs, modals, back — all through one method
Type-safe parameters Blazor [Parameter] Compile-time binding, no dictionaries
Route templates ASP.NET Core / Blazor {param}, {param:int}, {param=default}, {*catchall}
Layout-based structure Expo Router Tabs, modals, stacks are layout containers, not separate APIs
ViewModel lifecycle Prism INavigationAware OnNavigatedTo, OnNavigatingFrom, CanNavigate
Navigation results Prism INavigationResult Every navigation returns success/failure
DI integration Prism + .NET MAUI Pages created by container, ViewModels auto-wired
Deep linking Expo Router / SwiftUI URL = route = screen, no separate mapping layer
Scoped parameters SwiftUI (each value in path owns its data) No flat dictionary; each route segment is a typed value

Part 3: The Perfect Navigation System

Core Concept: Navigation Graph + Typed Routes + One API

┌─────────────────────────────────────────────┐
│              NavigationGraph                 │
│  (registered at startup, compile-time safe)  │
├─────────────────────────────────────────────┤
│                                             │
│  Tab("products")                            │
│    ├── Root: ProductCatalogPage             │
│    ├── Route: product/{sku}  → ProductPage  │
│    └── Route: product/{sku}/review/{stars}  │
│              → ReviewPage                   │
│                                             │
│  Tab("orders")                              │
│    ├── Root: OrderListPage                  │
│    └── Route: order/{id:int} → OrderPage    │
│                                             │
│  Modal("permissions") → PermissionsPage     │
│  Modal("settings")    → SettingsPage        │
│                                             │
│  Global("files/{*path}") → FileBrowserPage  │
│                                             │
└─────────────────────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────────────┐
│           INavigationService                │
│  (one method for everything)                │
├─────────────────────────────────────────────┤
│                                             │
│  NavigateAsync(route, params?) → Result     │
│  GoBackAsync(params?) → Result              │
│  CanGoBack: bool                            │
│  CurrentRoute: NavigationState              │
│                                             │
│  Events:                                    │
│    Navigating (cancelable)                  │
│    Navigated                                │
│    NavigationFailed                         │
│                                             │
└─────────────────────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────────────┐
│           ViewModel Lifecycle               │
│  (automatic, DI-wired)                      │
├─────────────────────────────────────────────┤
│                                             │
│  INavigatedTo.OnNavigatedTo(params)         │
│  INavigatingFrom.OnNavigatingFrom(context)  │
│  INavigationGuard.CanNavigateAsync(params)  │
│                                             │
└─────────────────────────────────────────────┘

1. Typed Route Definitions (compile-time safe)

Inspired by SwiftUI's Hashable routes and Blazor's [Parameter], but adapted for .NET's type system:

// Option A: Record-based route definitions (SwiftUI-inspired)
public record ProductRoute(string Sku) : IRoute;
public record ReviewRoute(string Sku, int Stars = 5) : IRoute;
public record OrderRoute([Constraint<int>] string Id) : IRoute;
public record FileRoute([CatchAll] string Path) : IRoute;

// Option B: Attribute-based (Blazor-inspired, works with existing pages)
[Route("product/{Sku}")]
public partial class ProductPage : ContentPage
{
    [RouteParameter] public string Sku { get; set; }
}

[Route("review/{Sku}/{Stars:int=5}")]
public partial class ReviewPage : ContentPage
{
    [RouteParameter] public string Sku { get; set; }
    [RouteParameter] public int Stars { get; set; }
}

// Source generator emits: route registration, parameter binding,
// type-safe navigation extensions, deep link mapping

Source generators produce:

  • Route registration code (no manual RegisterRoute calls)
  • Type-safe NavigateToProduct(sku) extension methods
  • Parameter binding (no [QueryProperty], no dictionaries)
  • Deep link URL ↔ route mapping
  • Compile-time validation of route constraints

2. Navigation Graph Registration

// In MauiProgram.cs
builder.Services.AddNavigation(nav =>
{
    nav.AddTab<ProductCatalogPage>("products", tab =>
    {
        tab.Title = "Products";
        tab.Icon = "groceries.png";
        tab.AddRoute<ProductPage>();      // route from [Route] attribute
        tab.AddRoute<ReviewPage>();       // route from [Route] attribute
    });

    nav.AddTab<OrderListPage>("orders", tab =>
    {
        tab.Title = "Orders";
        tab.Icon = "orders.png";
        tab.AddRoute<OrderPage>();
    });

    nav.AddModal<PermissionsPage>();
    nav.AddModal<SettingsPage>();
    nav.AddRoute<FileBrowserPage>();      // global route
});

Key properties:

  • Every route registered in ONE place through DI
  • Tabs, modals, and pushable routes use the same system
  • No XAML required (but could be supported as syntactic sugar)
  • Source-generated routes are auto-discovered via [Route] attribute

3. Navigation API

public interface INavigationService
{
    // Type-safe navigation (source-generated extensions)
    // navigator.ToProduct("seed-tomato")
    // navigator.ToReview("seed-tomato", stars: 3)
    // navigator.ToOrder(42)

    // URI-based navigation (for dynamic/deep link scenarios)
    Task<NavigationResult> NavigateAsync(string route,
        INavigationParameters? parameters = null,
        NavigationOptions? options = null);

    // Back navigation
    Task<NavigationResult> GoBackAsync(INavigationParameters? parameters = null);

    // State
    bool CanGoBack { get; }
    NavigationState CurrentState { get; }

    // Events
    event EventHandler<NavigatingEventArgs> Navigating;     // cancelable
    event EventHandler<NavigatedEventArgs> Navigated;
    event EventHandler<NavigationFailedEventArgs> NavigationFailed;
}

public class NavigationOptions
{
    public PresentationMode Presentation { get; set; } = PresentationMode.Push;
    public bool Animated { get; set; } = true;
    public TabOptions? CreateTab { get; set; }  // non-null = create a new tab
}

public enum PresentationMode
{
    Push,       // standard push onto current stack
    Modal,      // modal presentation
    Replace,    // replace current page
    Root        // reset to root and push
}

public class NavigationResult
{
    public bool Succeeded { get; }
    public Exception? Exception { get; }
    public string? FailureReason { get; }
}

4. ViewModel Lifecycle

// Minimal: just receive parameters
public class ProductViewModel : ObservableObject, INavigatedTo
{
    public void OnNavigatedTo(INavigationParameters parameters)
    {
        Sku = parameters.Get<string>("sku");
    }
}

// Full lifecycle
public class EditViewModel : ObservableObject, INavigatedTo, INavigatingFrom, INavigationGuard
{
    public void OnNavigatedTo(INavigationParameters parameters) { /* load */ }

    public void OnNavigatingFrom(NavigatingFromContext context) { /* cleanup */ }

    public async Task<bool> CanNavigateAsync(INavigationParameters parameters)
    {
        if (HasUnsavedChanges)
            return await Shell.Current.DisplayAlert("Discard?", "You have unsaved changes", "Discard", "Cancel");
        return true;
    }
}

// Source-generated: parameters are injected directly
[Route("product/{Sku}")]
public partial class ProductViewModel : ObservableObject
{
    [RouteParameter] public string Sku { get; set; }
    // No OnNavigatedTo needed — source generator wires it
}

5. Navigation State as Data (SwiftUI-inspired)

// The navigation state is a serializable value
public class NavigationState
{
    public IReadOnlyList<RouteEntry> Stack { get; }
    public IReadOnlyList<TabState> Tabs { get; }
    public RouteEntry? Modal { get; }

    // Serialize for state restoration / deep linking
    public string ToUri() => "//products/product/seed-tomato/review/5";
    public static NavigationState FromUri(string uri) => /* parse */;

    // Equality for testing
    public bool Equals(NavigationState other) => /* value equality */;
}

// Deep linking = setting state
await navigator.RestoreStateAsync(NavigationState.FromUri(deepLinkUri));

// Testing = comparing state
var expected = NavigationState.FromUri("//products/product/seed-tomato");
Assert.Equal(expected, navigator.CurrentState);

Part 4: How This Replaces What We Have

Migration Strategy: New Core, Old Shell on Top

┌─────────────────────────────────────┐
│     Existing Shell API (facade)      │  ← AppShell.xaml, GoToAsync(string),
│     Routing.RegisterRoute, etc.      │     [QueryProperty], IQueryAttributable
├─────────────────────────────────────┤
│     New Navigation Core              │  ← INavigationService, NavigationGraph,
│     (the "perfect" system)           │     typed routes, ViewModel lifecycle
├─────────────────────────────────────┤
│     Platform Renderers               │  ← TabBar, NavigationBar, Modal
│     (shared across old + new)        │     presentation, back gestures
└─────────────────────────────────────┘

Phase 1: Build the new core (INavigationService, NavigationGraph, typed routes, ViewModel lifecycle). Ship it alongside Shell. New apps use the new system; existing apps keep Shell.

Phase 2: Implement Shell as a thin facade on top of the new core. GoToAsync(string) calls INavigationService.NavigateAsync. [QueryProperty] maps to [RouteParameter]. AppShell.xaml compiles to NavigationGraph registration.

Phase 3: Deprecate Shell-specific APIs. New documentation points to the new system. Shell remains for backward compat but is no longer the recommended path.


Part 5: Comparison Matrix

Capability Shell Today Prism SwiftUI Jetpack Compose Expo Router Blazor Perfect
Unified registration ❌ Split ⚠️
Type-safe routes ❌ Strings ❌ Strings ✅ Enums ⚠️ Community ✅ TS [Parameter] ✅ Source-gen
Type-safe parameters ❌ Dictionary ❌ Dictionary ✅ Value types ⚠️ Bundle ✅ TS [Parameter] [RouteParameter]
Route templates ✅ PR #35110
Route constraints ✅ PR #35110
Programmatic tabs ❌ XAML only ❌ structural ❌ structural ✅ layout N/A
Programmatic modals ❌ bypass Shell ⚠️ separate API ⚠️ separate ✅ layout N/A
Navigation result ❌ Silent
ViewModel lifecycle ❌ Page only ✅ Full N/A ⚠️ SavedStateHandle ⚠️ Component ✅ Full
Navigation guards ⚠️ redirect
State as data ⚠️ ✅ URL ⚠️ URL
Deep linking ⚠️ manual ⚠️ manual ✅ automatic ✅ automatic
DI-first ⚠️ N/A ✅ Hilt N/A
Scoped parameters ❌ Flat dict ⚠️ ⚠️
Source generation N/A ✅ Compose Dest ✅ file-based ⚠️
Back navigation .. ✅ browser
Testable without UI ❌ needs Shell ⚠️ ⚠️ ⚠️

Summary

The perfect navigation system for .NET MAUI combines:

  1. SwiftUI's state model — navigation state is data, not UI tree mutations
  2. Blazor's route templates{param:constraint=default} with compile-time binding
  3. Prism's unified API — one service for push, tabs, modals, back
  4. Prism's ViewModel lifecycleINavigatedTo, INavigatingFrom, INavigationGuard
  5. Expo Router's layout unification — tabs and modals are just presentation modes
  6. Source generation — no manual registration, type-safe navigation methods, compile-time validation
  7. .NET's DI system — pages and ViewModels created by IServiceProvider

The result: you write [Route("product/{Sku}")] on your page/ViewModel, register it in DI, and get type-safe navigation, deep linking, state restoration, and ViewModel lifecycle — all generated at compile time, all testable without UI, all working through one INavigationService.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment