Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Guide for implementing Shell-based navigation in .NET MAUI apps. Covers AppShell setup, visual hierarchy (FlyoutItem, TabBar, Tab, ShellContent), URI-based navigation with GoToAsync, route registration, query parameters, back navigation, flyout and tab configuration, navigation events, and navigation guards. Use when: setting up Shell navigation, adding tabs or flyout menus, navigating between pages with GoToAsync, passing parameters between pages, registering routes, customizing back button beh
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 103% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 91% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 165% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 173% | 0% |
| case-04 | ✓→✗ | ▼ Worse | 116% | 0% |
Implement page navigation in .NET MAUI apps using Shell. Shell provides URI-based navigation, a flyout menu, tab bars, and a four-level visual hierarchy — all configured declaratively in XAML.
GoToAsyncmaui-data-bindingmaui-dependency-injectionNavigationPage without Shell (different navigation API)AppShell.xaml as the root shellContentPage) to navigate betweenThese are the Shell-specific decisions that are easy to get wrong. Apply them whenever they are relevant to what the user asked.
| Situation | Do this | Not this | |---|---|---| | Declaring pages in AppShell.xaml | With xmlns:views="clr-namespace:MyApp.Views" declared: <ShellContent ContentTemplate="{DataTemplate views:MyPage}" /> — the page is created on first navigation | <ShellContent><views:MyPage /></ShellContent>, which constructs every page at startup | | Navigating to a page not in the visual hierarchy | Routing.RegisterRoute("details", typeof(DetailsPage)) first | Calling GoToAsync("details") unregistered — it throws at runtime | | Receiving navigation parameters | Implement IQueryAttributable on the ViewModel | Implementing it on the Page, which splits state from the BindingContext | | Passing a whole object | ShellNavigationQueryParameters | Serialising the object into the query string | | Any GoToAsync call | await it | Fire-and-forget — exceptions are swallowed and navigation races | | Confirming before back navigation | ShellNavigatingEventArgs.GetDeferral() … deferral.Complete() | Blocking synchronously on the dialog task | | Detecting back navigation | Check e.Source == ShellNavigationSource.Pop | Assuming every navigation is a back action |
Do not propose NavigationPage / PushAsync solutions for a Shell app, and do not restructure a working AppShell hierarchy unless the user asked.
Answer narrowly, but completely. Staying on topic does not mean being terse. When you show a navigation change, include the pieces needed to run it: the AppShell.xaml markup and the Routing.RegisterRoute call, or the GoToAsync call and the receiving IQueryAttributable / [QueryProperty] code. Where two approaches are both valid (query string vs ShellNavigationQueryParameters), show both and say when each fits — a single snippet the user still has to complete is a worse answer.
Shell uses a four-level hierarchy. Each level wraps the one below it:
Shell
├── FlyoutItem / TabBar (top-level grouping)
│ ├── Tab (bottom-tab grouping)
│ │ ├── ShellContent (page slot → ContentPage)
│ │ └── ShellContent (multiple = top tabs)
│ └── Tab
└── FlyoutItem / TabBarTab childrenShellContent; multiple children produce top tabsContentPageYou can omit intermediate wrappers. Shell auto-wraps:
| You write | Shell creates | |------------------------------|---------------------------------------| | ShellContent only | FlyoutItem > Tab > ShellContent | | Tab only | FlyoutItem > Tab | | ShellContent in TabBar | TabBar > Tab > ShellContent |
AppShell.xaml inheriting from ShellFlyoutItem or TabBar elements for top-level navigationTab elements for bottom tabs; nest multiple ShellContent for top tabsContentTemplate with DataTemplate so pages load on demandShellContent an explicit Route (see below)AppShell constructor> Set Route= on every ShellContent. If you omit it, MAUI auto-generates a > name from a shared counter — Routing.cs produces D_FAULT_{TypeName}{n}. A real > shell with three unnamed ShellContent elements yields routes like > D_FAULT_ShellContent2 and D_FAULT_ShellContent5: the numbers are not > sequential, they depend on how many Shell elements were constructed first, and they > shift when you reorder or add pages. You cannot write a stable absolute route > (//dashboard) or deep link against that. An explicit Route="dashboard" is stable > forever.
xml<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" FlyoutBehavior="Flyout"> <FlyoutItem Title="Animals" Icon="animals.png"> <Tab Title="Cats"> <ShellContent Title="Domestic" Route="domesticcats" ContentTemplate="{DataTemplate views:DomesticCatsPage}" /> <ShellContent Title="Wild" Route="wildcats" ContentTemplate="{DataTemplate views:WildCatsPage}" /> </Tab> <Tab Title="Dogs" Icon="dogs.png"> <ShellContent Route="dogs" ContentTemplate="{DataTemplate views:DogsPage}" /> </Tab> </FlyoutItem> <TabBar> <ShellContent Title="Home" Icon="home.png" Route="home" ContentTemplate="{DataTemplate views:HomePage}" /> <ShellContent Title="Settings" Icon="settings.png" Route="settings" ContentTemplate="{DataTemplate views:SettingsPage}" /> </TabBar> </Shell>
csharp// AppShell.xaml.cs public partial class AppShell : Shell { public AppShell() { InitializeComponent(); Routing.RegisterRoute("animaldetails", typeof(AnimalDetailsPage)); Routing.RegisterRoute("editanimal", typeof(EditAnimalPage)); } }
All programmatic navigation uses Shell.Current.GoToAsync. Always await the call.
| Prefix | Meaning | |--------|---------------------------------------------| | // | Absolute route from Shell root | | (none) | Relative; pushes onto the current nav stack | | .. | Go back one level | | ../ | Go back then navigate forward |
csharp// 1. Absolute — switch to a specific hierarchy location await Shell.Current.GoToAsync("//animals/cats/domestic"); // 2. Relative — push a registered detail page await Shell.Current.GoToAsync("animaldetails"); // 3. With query string parameters await Shell.Current.GoToAsync($"animaldetails?id={animal.Id}"); // 4. Go back one page await Shell.Current.GoToAsync(".."); // 5. Go back two pages await Shell.Current.GoToAsync("../.."); // 6. Go back one page, then push a different page await Shell.Current.GoToAsync("../editanimal");
Implement on ViewModels to receive all parameters in one call:
csharppublic class AnimalDetailsViewModel : ObservableObject, IQueryAttributable { public void ApplyQueryAttributes(IDictionary<string, object> query) { if (query.TryGetValue("id", out var id)) AnimalId = id.ToString(); } }
Apply on the ViewModel class (or the page, if it genuinely owns the state). Prefer IQueryAttributable on the ViewModel — it keeps navigation state with the BindingContext and handles multiple parameters in one call:
csharp[QueryProperty(nameof(AnimalId), "id")] public partial class AnimalDetailsViewModel : ObservableObject { [ObservableProperty] private string _animalId = string.Empty; }
Shell applies query attributes after the page constructor sets BindingContext, so the property must raise change notification — a plain auto-property leaves the binding stuck on its initial value.
Pass objects without serializing to strings:
csharpvar parameters = new ShellNavigationQueryParameters { { "animal", selectedAnimal } }; await Shell.Current.GoToAsync("animaldetails", parameters);
Receive via IQueryAttributable:
csharppublic void ApplyQueryAttributes(IDictionary<string, object> query) { Animal = query["animal"] as Animal; }
Use GetDeferral() in OnNavigating for async checks (e.g., "save unsaved changes?"):
csharp// In AppShell.xaml.cs protected override async void OnNavigating(ShellNavigatingEventArgs args) { base.OnNavigating(args); if (hasUnsavedChanges && args.Source == ShellNavigationSource.Pop) { var deferral = args.GetDeferral(); bool discard = await ShowConfirmationDialog(); if (!discard) args.Cancel(); deferral.Complete(); } }
Multiple ShellContent (or Tab) children inside a TabBar or FlyoutItem produce bottom tabs.
Multiple ShellContent children inside a single Tab produce top tabs:
xml<Tab Title="Photos"> <ShellContent Title="Recent" ContentTemplate="{DataTemplate views:RecentPage}" /> <ShellContent Title="Favorites" ContentTemplate="{DataTemplate views:FavoritesPage}" /> </Tab>
| Attached Property | Type | Purpose | |--------------------------------|---------|--------------------------------| | Shell.TabBarBackgroundColor | Color | Tab bar background | | Shell.TabBarForegroundColor | Color | Selected icon color | | Shell.TabBarTitleColor | Color | Selected tab title color | | Shell.TabBarUnselectedColor | Color | Unselected tab icon/title | | Shell.TabBarIsVisible | bool | Show/hide the tab bar |
xml<!-- Hide the tab bar on a specific page --> <ContentPage Shell.TabBarIsVisible="False" ... />
Set on Shell: Disabled, Flyout, or Locked.
xml<Shell FlyoutBehavior="Flyout"> ... </Shell>
Controls how children appear in the flyout:
AsSingleItem (default) — one flyout entry for the groupAsMultipleItems — each child Tab gets its own entryxml<FlyoutItem Title="Animals" FlyoutDisplayOptions="AsMultipleItems"> <Tab Title="Cats" ... /> <Tab Title="Dogs" ... /> </FlyoutItem>
xml<MenuItem Text="Log Out" Command="{Binding LogOutCommand}" IconImageSource="logout.png" />
Customize the back button per page:
xml<Shell.BackButtonBehavior> <BackButtonBehavior Command="{Binding BackCommand}" IconOverride="back_arrow.png" TextOverride="Cancel" IsVisible="True" /> </Shell.BackButtonBehavior>
Properties: Command, CommandParameter, IconOverride, TextOverride, IsVisible, IsEnabled.
csharp// Current URI location string location = Shell.Current.CurrentState.Location.ToString(); // Current page Page page = Shell.Current.CurrentPage; // Navigation stack of the current tab IReadOnlyList<Page> stack = Shell.Current.Navigation.NavigationStack;
Override in AppShell:
csharpprotected override void OnNavigated(ShellNavigatedEventArgs args) { base.OnNavigated(args); // args.Current, args.Previous, args.Source }
ShellNavigationSource values: Push, Pop, PopToRoot, Insert, Remove, ShellItemChanged, ShellSectionChanged, ShellContentChanged, Unknown.
Content directly instead of ContentTemplate with DataTemplate creates all pages at Shell init, hurting startup time. Always use ContentTemplate.Routing.RegisterRoute throws ArgumentException if a route name matches an existing route or a visual hierarchy route. Every route must be unique across the app.GoToAsync("somepage") unless somepage was registered with Routing.RegisterRoute. Visual hierarchy pages use absolute // routes.GoToAsync causes race conditions and silent failures. Always await the call.//FlyoutItem/Tab/ShellContent). Wrong paths produce silent no-ops, not exceptions.GoToAsync for all navigation changes.GetDeferral() for async guards: Synchronous cancellation in OnNavigating works, but async checks require GetDeferral() / deferral.Complete() to avoid race conditions.references/shell-navigation-api.md — Full API reference for Shell hierarchy, routes, tabs, flyout, and navigationOther measured skills in the registry, with their headline benchmark lift.