Skip to content
Shiny.NET
Shiny MAUI Shell v7 - App Links, App Shortcuts, & Navigation Interception!Shortcut me to it

Shell | Navigation Interceptors

INavigationInterceptor sits in front of every navigation the app makes and can let it through, cancel it, or send it somewhere else. Register as many as you like — they run in registration order and the first one to cancel or redirect wins.

public interface INavigationInterceptor
{
Task<NavigationInterceptorResult> InterceptNavigationAsync(
string uri,
object? viewModel,
CancellationToken cancellationToken
);
// Lowest runs first; ties keep registration order
int Order => 0;
}

Every navigation method returns Task<bool>false means a guard cancelled it:

if (!await navigator.NavigateTo<DetailViewModel>())
// an interceptor said no
public class AuthNavigationInterceptor(IAuthService auth) : INavigationInterceptor
{
// Guards run before anything that only observes
public int Order => -100;
public async Task<NavigationInterceptorResult> InterceptNavigationAsync(
string uri,
object? viewModel,
CancellationToken cancellationToken
)
{
if (await auth.IsAuthorized(cancellationToken) || uri.Contains("Login"))
return NavigationInterceptorResult.Continue;
return NavigationInterceptorResult.Redirect<LoginViewModel>();
}
}
MauiProgram.cs
builder.UseShinyShell(x => x
.AddGeneratedMaps()
.AddNavigationInterceptor<AuthNavigationInterceptor>()
.AddNavigationInterceptor<AuditNavigationInterceptor>()
// or inline, when a class would be ceremony
.AddNavigationInterceptor((uri, vm, ct) =>
{
Console.WriteLine($"Navigating to {uri}");
return Task.FromResult(NavigationInterceptorResult.Continue);
}, order: 100)
);

Interceptors run in Order (lowest first), then registration order — so a guard can be pinned ahead of an audit log without the two registrations having to know about each other.

Interceptors are registered as singletons, so keep no per-navigation state in fields. Register them yourself (builder.Services.AddTransient<INavigationInterceptor, MyInterceptor>()) for any other lifetime — the pipeline resolves them fresh on each navigation.

Path Intercepted viewModel argument
NavigateTo(route) Resolved from the route’s ViewModel mapping
NavigateTo<TViewModel>(configure) Your instance, after configure ran
CreateBuilder()…Navigate() The last segment’s ViewModel — the page the user lands on
GoBack / PopToRoot The existing ViewModel from the navigation stack
App links & app shortcuts The ViewModel with the link’s values already applied
Tab taps, flyout items, hardware back null
ShowDialog / SwitchShell

Deep links are the reason this exists: an inbound URL can arrive at any moment, at any route, from outside the app, and it goes through exactly the same guards as a button tap.

The ViewModel handed over is the destination one, resolved from DI and fully populated before the interceptor is called — the configure callback has run, an app link’s values are already bound. So a guard can decide on the destination’s own state rather than parsing its URI:

public Task<NavigationInterceptorResult> InterceptNavigationAsync(string uri, object? viewModel)
=> Task.FromResult(viewModel is OrderViewModel { RequiresApproval: true } && !user.IsManager
? NavigationInterceptorResult.Cancel()
: NavigationInterceptorResult.Continue
);

That instance is the one bound to the page, so changes an interceptor makes stick. Two exceptions: navigation arguments are applied by Shell afterwards and win over an interceptor’s edit to the same property, and a route declared as a ShellContent in AppShell XAML keeps whatever ViewModel its page is already bound to — the interceptor sees a resolved instance and can still cancel or redirect on it, but a change it makes does not reach an on-screen page.

It is null when the destination route has no ViewModel mapping, and for user-driven Shell navigation — a tab tap or the hardware back button — where Shell builds the destination itself and Shiny will not construct a ViewModel nobody binds.

Inject INavigationContextAccessor for everything that does not fit in the two arguments:

public class UnsavedChangesNavigationInterceptor(
INavigationContextAccessor context,
IDialogs dialogs
) : INavigationInterceptor
{
public async Task<NavigationInterceptorResult> InterceptNavigationAsync(string uri, object? viewModel)
{
if (context.Current?.FromViewModel is not IUnsavedChanges { HasUnsavedChanges: true })
return NavigationInterceptorResult.Continue;
return await dialogs.Confirm("Unsaved Changes", "Discard changes?")
? NavigationInterceptorResult.Continue
: NavigationInterceptorResult.Cancel();
}
}

NavigationContext carries FromUri, FromViewModel, ToUri, NavigationType, Direction, Parameters and RedirectCount. Current is null outside an interceptor call.

Interceptors run on the main thread, so a dialog can be awaited directly — the navigation waits.

Direction is the coarse question NavigationType answers precisely, for the guard that only cares which way the user is going:

NavigationDirection From
Forward Push
Back GoBack, PopToRoot
Root SetRoot (an absolute //route), SwitchShell
if (context.Current?.Direction == NavigationDirection.Back)
return NavigationInterceptorResult.Continue; // never guard the way out

It is on NavigationEventArgs and NavigatedEventArgs too, and any NavigationType converts with navigationType.GetDirection().

A guard that navigates would otherwise guard itself. Every navigation method takes bypassInterceptors:

await navigator.NavigateTo<LoginViewModel>(bypassInterceptors: true);
await navigator.GoBack(1, bypassInterceptors: true);
await navigator.PopToRoot(bypassInterceptors: true);
await navigator.CreateBuilder().AddDetail(42).Navigate(bypassInterceptors: true);
// the builder is fluent, so it reads fluently too - call it anywhere in the chain
await navigator.CreateBuilder().BypassInterceptors().AddDetail(42).Navigate();

A RedirectUri never needs it — the chain restarting on a redirect is deliberate, and a redirect to the destination already being navigated to is ignored rather than looping.

bypassInterceptors is about the interceptor chain only: it does not skip INavigationConfirmation, which is the ViewModel’s own guard on the page being left.

Interceptors receive the CancellationToken passed to the navigation call — for the network call an auth guard makes, not for the decision itself, which is what Cancel() is for. Cancelling it abandons the navigation with an OperationCanceledException.

await navigator.NavigateTo<DetailViewModel>(cancellationToken: cts.Token);
Result Behaviour
NavigationInterceptorResult.Continue Next interceptor, then navigate
Cancel() Nothing navigates; the rest of the chain is skipped
Redirect("Detail") Pushes
Redirect("//Main/Home") Resets the Shell stack
Redirect("/Login") Same as //Login — a single leading slash is promoted
Redirect<LoginViewModel>() Resets the stack to that ViewModel’s route
Redirect<DetailViewModel>(relativeNavigation: true) Pushes that ViewModel’s route

Prefer the typed Redirect<TViewModel>() — the route comes from the ViewModel map, so renaming a route does not silently break a guard.

CancelNavigation wins if a result sets both. A cancelled navigation is not a failure: the caller’s Task completes normally and the user simply stays where they are.

An exception thrown from an interceptor propagates to the caller and the navigation does not happen — a guard that fails is never treated as a guard that passed. On tab taps and hardware back, where there is no caller to throw to, the exception is logged and the navigation is cancelled.

IAppLinks.Handle returns an AppLinkResult: Navigated, Blocked (a guard cancelled it) or Unhandled (nothing matched). The platform hooks still report a blocked link as handled — telling iOS otherwise invites it to open the URL in a browser instead, which is the opposite of what a guard that just blocked it wants. The distinction is there for the Windows case, where you forward activation by hand, and for logging.

Scope Can cancel Can redirect
INavigationInterceptor App-wide, about the destination, every navigation
INavigationConfirmation One ViewModel, about leaving it, user-driven navigation only
Navigating / Navigated events App-wide, observation only

INavigationConfirmation is asked only when the user navigates away (tab tap, flyout item, hardware back button) — programmatic navigation has never consulted it — and it is asked before the interceptors. Use it for “this page has unsaved work”; use an interceptor when the rule belongs to the app rather than to one page, when it must also cover INavigator calls and inbound links, or when it needs to redirect.