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.
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.
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.
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.
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.
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.
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.
| 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 |
┌─────────────────────────────────────────────┐
│ 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) │
│ │
└─────────────────────────────────────────────┘
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 mappingSource generators produce:
- Route registration code (no manual
RegisterRoutecalls) - Type-safe
NavigateToProduct(sku)extension methods - Parameter binding (no
[QueryProperty], no dictionaries) - Deep link URL ↔ route mapping
- Compile-time validation of route constraints
// 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
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; }
}// 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
}// 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);┌─────────────────────────────────────┐
│ 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.
| Capability | Shell Today | Prism | SwiftUI | Jetpack Compose | Expo Router | Blazor | Perfect |
|---|---|---|---|---|---|---|---|
| Unified registration | ❌ Split | ✅ | ✅ | ✅ | ✅ | ✅ | |
| Type-safe routes | ❌ Strings | ❌ Strings | ✅ Enums | ✅ TS | ✅ [Parameter] |
✅ Source-gen | |
| Type-safe parameters | ❌ Dictionary | ❌ Dictionary | ✅ Value types | ✅ TS | ✅ [Parameter] |
✅ [RouteParameter] |
|
| Route templates | ✅ PR #35110 | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
| Route constraints | ✅ PR #35110 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| Programmatic tabs | ❌ XAML only | ✅ | ❌ structural | ❌ structural | ✅ layout | N/A | ✅ |
| Programmatic modals | ❌ bypass Shell | ✅ | ✅ layout | N/A | ✅ | ||
| Navigation result | ❌ Silent | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
| ViewModel lifecycle | ❌ Page only | ✅ Full | N/A | ❌ | ✅ Full | ||
| Navigation guards | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | |
| State as data | ❌ | ❌ | ✅ | ✅ URL | ✅ | ||
| Deep linking | ✅ | ✅ | ✅ 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 | ✅ | ✅ | ✅ |
The perfect navigation system for .NET MAUI combines:
- SwiftUI's state model — navigation state is data, not UI tree mutations
- Blazor's route templates —
{param:constraint=default}with compile-time binding - Prism's unified API — one service for push, tabs, modals, back
- Prism's ViewModel lifecycle —
INavigatedTo,INavigatingFrom,INavigationGuard - Expo Router's layout unification — tabs and modals are just presentation modes
- Source generation — no manual registration, type-safe navigation methods, compile-time validation
- .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.