Shell Navigation in .NET MAUI: Routes, Parameters, and Deep Linking

Navigation in mobile apps has always been fiddly. .NET MAUI's Shell provides a URI-based navigation system that handles tab bars, flyout menus, and page routing in a single, coherent model. If you're not using Shell, you're probably doing more work than you need to.

Defining Your Shell Structure

The AppShell.xaml file defines your app's visual hierarchy — tabs, flyout items, and the pages within them:

AppShell.xaml
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:views="clr-namespace:MyApp.Views"
       x:Class="MyApp.AppShell">

    <TabBar>
        <ShellContent Title="Home"
                      Icon="home.png"
                      ContentTemplate="{DataTemplate views:HomePage}" />
        <ShellContent Title="Settings"
                      Icon="settings.png"
                      ContentTemplate="{DataTemplate views:SettingsPage}" />
    </TabBar>
</Shell>

Using ContentTemplate with DataTemplate ensures pages are created lazily — they won't be instantiated until the user navigates to them.

Route Registration

Pages that appear in the Shell hierarchy get implicit routes based on their position. But for pages you navigate to programmatically — detail pages, modal forms — you register explicit routes:

AppShell.xaml.cs
public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();

        Routing.RegisterRoute("itemdetail", typeof(ItemDetailPage));
        Routing.RegisterRoute("itemedit", typeof(ItemEditPage));
        Routing.RegisterRoute("profile", typeof(ProfilePage));
    }
}

Routes are hierarchical. If itemdetail is registered as a global route, you can navigate to it from anywhere with //itemdetail. Relative routes navigate relative to the current position.

Navigation uses URI strings, which feels unusual at first but proves remarkably flexible:

Example.cs
// Absolute navigation — go to a top-level route
await Shell.Current.GoToAsync("//settings");

// Relative navigation — push onto the current stack
await Shell.Current.GoToAsync("itemdetail");

// Go back
await Shell.Current.GoToAsync("..");

// Go back two levels
await Shell.Current.GoToAsync("../..");

Passing Data with Query Parameters

The cleanest way to pass data between pages is with query parameters and the [QueryProperty] attribute — or better yet, IQueryAttributable:

Example.cs
// Navigating with parameters
await Shell.Current.GoToAsync($"itemdetail?id={item.Id}");

// Receiving parameters — the simple way
[QueryProperty(nameof(ItemId), "id")]
public partial class ItemDetailPage : ContentPage
{
    public string ItemId { get; set; }
}

For view models, implement IQueryAttributable for more control:

Example.cs
public class ItemDetailViewModel : ObservableObject, IQueryAttributable
{
    private readonly IItemService _itemService;

    public ItemDetailViewModel(IItemService itemService)
    {
        _itemService = itemService;
    }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("id", out var idObj) && idObj is string id)
        {
            LoadItem(id);
        }
    }

    private async void LoadItem(string id)
    {
        Item = await _itemService.GetByIdAsync(id);
    }

    [ObservableProperty]
    private Item _item;
}

Passing Complex Objects

You're not limited to strings. Shell navigation supports passing objects directly:

Example.cs
var parameters = new Dictionary<string, object>
{
    { "item", selectedItem },
    { "editMode", true }
};

await Shell.Current.GoToAsync("itemedit", parameters);

Receive them in the view model:

Example.cs
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
    if (query.TryGetValue("item", out var obj) && obj is Item item)
    {
        CurrentItem = item;
    }

    if (query.TryGetValue("editMode", out var mode) && mode is bool edit)
    {
        IsEditing = edit;
    }
}

You can intercept navigation to implement guards — confirming unsaved changes, requiring authentication, or logging analytics:

Example.cs
public partial class EditPage : ContentPage
{
    private bool _hasUnsavedChanges;

    protected override bool OnBackButtonPressed()
    {
        if (_hasUnsavedChanges)
        {
            PromptSave();
            return true; // prevent default back navigation
        }
        return false; // allow normal back navigation
    }

    private async void PromptSave()
    {
        var save = await DisplayAlert(
            "Unsaved Changes",
            "Do you want to save before leaving?",
            "Save", "Discard");

        if (save)
            await SaveChanges();

        await Shell.Current.GoToAsync("..");
    }
}

Flyout Navigation

Shell handles flyout menus with the same model:

config.xml
<Shell FlyoutBehavior="Flyout">
    <FlyoutItem Title="Dashboard" Icon="dashboard.png">
        <ShellContent ContentTemplate="{DataTemplate views:DashboardPage}" />
    </FlyoutItem>
    <FlyoutItem Title="Orders" Icon="orders.png">
        <ShellContent ContentTemplate="{DataTemplate views:OrdersPage}" />
    </FlyoutItem>

    <!-- Separator and non-navigating items -->
    <MenuItem Text="Log Out" Command="{Binding LogOutCommand}" />
</Shell>

Tips for Production Apps

Use constants for route names. Magic strings scattered through your codebase will bite you:

Example.cs
public static class Routes
{
    public const string ItemDetail = "itemdetail";
    public const string ItemEdit = "itemedit";
    public const string Profile = "profile";
}

// Usage
await Shell.Current.GoToAsync(Routes.ItemDetail);

Register routes at startup. All Routing.RegisterRoute calls should happen in the AppShell constructor so routes are available immediately.

Prefer IQueryAttributable over [QueryProperty]. The interface gives you a single place to handle all incoming parameters with proper type checking and async loading.

Shell navigation isn't perfect — deeply nested hierarchies can get unwieldy, and the URI syntax takes getting used to. But for the vast majority of mobile apps, it provides a clean, testable navigation model that's a significant step up from the manual Navigation.PushAsync approach.