Building a Robust Deep Link Router in .NET MAUI
π Building a Robust Deep Link Router in .NET MAUI
Deep linking looks deceptively simple. A user taps a URL:
https://example.com/products/42
The operating system launches your application, your .NET MAUI app receives the URI, and you navigate to the appropriate page. At least, that's how the happy path works. Real applications quickly introduce much harder scenarios:
https://example.com/products/42
https://example.com/orders/AB123
https://example.com/profile
https://example.com/promotions/summer?campaign=email
myapp://settings/notifications
Then additional requirements appear. What happens when the user isn't authenticated? What if the application hasn't finished initializing? What if the link arrives while another navigation operation is running? What if the route doesn't exist? What if an attacker sends malformed parameters? What if the link refers to content that no longer exists? What happens when the application is already running? And what happens when Android and iOS deliver essentially the same deep link through different platform mechanisms? At that point, deep linking is no longer simply:
await Shell.Current.GoToAsync(uri.ToString());
It becomes a routing problem.
A production application benefits from treating incoming links as external requests that must be parsed, validated, resolved, authorized, and finally translated into internal application navigation.
In this article, we'll build a reusable Deep Link Router for .NET MAUI that separates those responsibilities and provides a foundation for handling real-world deep linking safely. π
π Table of Contents
- What Is Deep Linking?
- Deep Links vs App Links
- Why Direct Navigation Becomes Fragile
- Designing the Architecture
- Defining a Deep Link Request
- Building a Route Definition
- Creating a Route Registry
- Parsing Incoming URIs
- Matching Dynamic Route Segments
- Extracting Route Parameters
- Handling Query Parameters
- Creating the Router
- Translating External Routes into Shell Routes
- Authentication-Aware Routing
- Deferred Deep Links
- Application Startup and Initialization
- Preventing Duplicate Deep Link Navigation
- Security and Input Validation
- Handling Unknown Routes
- Route Priorities
- Custom URI Schemes
- HTTPS App Links
- Android App Links
- iOS Considerations
- Navigation Context
- Route Handlers
- Testing the Router
- Logging and Diagnostics
- Common Mistakes
- Production Architecture
- Best Practices
- Conclusion
- References
1. π What Is Deep Linking?
A deep link is a URI that identifies a specific destination or action inside an application. Instead of opening only the application's home page:
https://example.com
a deep link might identify a specific product:
https://example.com/products/42
or an order:
https://example.com/orders/AB123
or an application feature:
myapp://settings/security
Conceptually:
External URI
β
βΌ
Operating System
β
βΌ
.NET MAUI Application
β
βΌ
Deep Link Router
β
βΌ
Application Destination
The URI is therefore an external representation of application intent. That's an important distinction. The URL:
https://example.com/products/42
doesn't necessarily need to match your internal Shell navigation route.
Your internal route could be:
product-details?id=42
The deep link router becomes the translation layer between the two.
2. π Deep Links vs App Links
The terminology varies slightly between platforms. At a high level, there are two common approaches.
Custom URI schemes
Example:
shaunebu://products/42
Your application registers the custom scheme:
shaunebu
The operating system can then launch your app when that scheme is invoked. Custom schemes are convenient, but they don't inherently establish ownership of a web domain.
HTTPS links
Example:
https://shaunebu.com/products/42
These are usually preferable when the same content also exists on the web.
Platform-specific verification mechanisms can associate your website with your application.
On Android, verified App Links use HTTP or HTTPS links and domain verification so the system can establish your application as the handler for the associated domain.
The architectural goal is still the same:
External URI
β
βΌ
Normalize
β
βΌ
Match
β
βΌ
Validate
β
βΌ
Navigate
3. β οΈ Why Direct Navigation Becomes Fragile
A tempting implementation is:
protected override async void OnAppLinkRequestReceived(Uri uri)
{
base.OnAppLinkRequestReceived(uri);
await Shell.Current.GoToAsync(uri.AbsolutePath);
}
This couples two completely different concepts:
Public URL Structure
=
Internal Navigation Structure
That becomes problematic. Suppose your public URL is:
/products/42
but your application navigation route is:
catalog/details
Changing your internal navigation should not break URLs that might already exist in:
- emails
- push notifications
- websites
- QR codes
- advertisements
- social media
- SMS messages
- external integrations
Deep links can live much longer than internal page structures.
A router provides isolation:
https://example.com/products/42
β
βΌ
Deep Link Router
β
βΌ
//catalog/details?id=42
Your public contract remains stable while the application evolves.
4. ποΈ Designing the Architecture
We'll separate deep link handling into several responsibilities.
βββββββββββββββββββββββββββββββββ
β Platform Integration β
β Android / iOS / Windows β
βββββββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββ
β Deep Link Receiver β
βββββββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββ
β URI Normalization β
βββββββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββ
β Route Registry β
β β
β /products/{id} β
β /orders/{number} β
β /profile β
βββββββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββ
β Route Matcher β
βββββββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββ
β Policy / Validation β
β β
β Authentication β
β Parameters β
β Application readiness β
βββββββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββ
β Navigation Layer β
βββββββββββββββββββββββββββββββββ
This architecture lets each component do one job.
5. π¦ Defining a Deep Link Request
Let's begin with a normalized representation of an incoming link.
public sealed record DeepLinkRequest(
Uri Uri,
string Path,
IReadOnlyDictionary<string, string> QueryParameters);
Instead of passing a raw Uri throughout the entire application, downstream components receive a predictable model.
We can later extend it with metadata such as:
public enum DeepLinkSource
{
Unknown,
AppLink,
CustomScheme,
PushNotification,
QRCode,
Internal
}
Then:
public sealed record DeepLinkRequest(
Uri Uri,
string Path,
IReadOnlyDictionary<string, string> QueryParameters,
DeepLinkSource Source);
This becomes useful for diagnostics and analytics.
6. πΊοΈ Building a Route Definition
We need to describe routes that the application understands.
public sealed record DeepLinkRoute(
string Pattern,
string Destination,
bool RequiresAuthentication = false);
Examples:
new DeepLinkRoute(
"/products/{id}",
"product-details");
new DeepLinkRoute(
"/orders/{number}",
"order-details",
RequiresAuthentication: true);
new DeepLinkRoute(
"/profile",
"profile",
RequiresAuthentication: true);
This creates a clear mapping between:
External Route
β
βΌ
Internal Destination
7. π Creating a Route Registry
Rather than scattering route definitions across ViewModels and pages, centralize them.
public interface IDeepLinkRouteRegistry
{
IReadOnlyList<DeepLinkRoute> Routes { get; }
}
Implementation:
public sealed class DeepLinkRouteRegistry
: IDeepLinkRouteRegistry
{
private readonly IReadOnlyList<DeepLinkRoute> _routes =
[
new(
"/products/{id}",
"product-details"),
new(
"/orders/{number}",
"order-details",
RequiresAuthentication: true),
new(
"/profile",
"profile",
RequiresAuthentication: true),
new(
"/settings/notifications",
"notification-settings",
RequiresAuthentication: true)
];
public IReadOnlyList<DeepLinkRoute> Routes => _routes;
}
Now supported deep links are discoverable in one location. That's much easier to maintain than:
App.xaml.cs
MainActivity.cs
AppDelegate.cs
ViewModel A
ViewModel B
Push Service
Notification Service
each implementing its own routing rules.
8. π Parsing Incoming URIs
We should normalize incoming URIs before matching them.
public interface IDeepLinkParser
{
DeepLinkRequest Parse(
Uri uri,
DeepLinkSource source = DeepLinkSource.Unknown);
}
Implementation:
public sealed class DeepLinkParser : IDeepLinkParser
{
public DeepLinkRequest Parse(
Uri uri,
DeepLinkSource source = DeepLinkSource.Unknown)
{
ArgumentNullException.ThrowIfNull(uri);
var path = NormalizePath(uri.AbsolutePath);
var query = ParseQuery(uri.Query);
return new DeepLinkRequest(
uri,
path,
query,
source);
}
private static string NormalizePath(string path)
{
if (string.IsNullOrWhiteSpace(path))
return "/";
var normalized = path.Trim();
if (!normalized.StartsWith('/'))
normalized = "/" + normalized;
if (normalized.Length > 1)
normalized = normalized.TrimEnd('/');
return normalized;
}
private static IReadOnlyDictionary<string, string> ParseQuery(
string query)
{
var result = new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(query))
return result;
var value = query.TrimStart('?');
foreach (var pair in value.Split(
'&',
StringSplitOptions.RemoveEmptyEntries))
{
var parts = pair.Split('=', 2);
var key = Uri.UnescapeDataString(parts[0]);
var itemValue = parts.Length > 1
? Uri.UnescapeDataString(parts[1])
: string.Empty;
result[key] = itemValue;
}
return result;
}
}
Now these:
/products/42
/products/42/
/products/42?campaign=email
produce a predictable route representation.
9. π§© Matching Dynamic Route Segments
Static routes are easy:
/profile
/settings
/help
Dynamic routes are more interesting:
/products/{id}
/orders/{number}
/users/{userId}/posts/{postId}
We need to compare:
/products/{id}
against:
/products/42
A simple segment-based matcher works well.
public sealed record DeepLinkMatch(
DeepLinkRoute Route,
IReadOnlyDictionary<string, string> RouteValues);
Then:
public interface IDeepLinkRouteMatcher
{
DeepLinkMatch? Match(
DeepLinkRequest request,
IEnumerable<DeepLinkRoute> routes);
}
Implementation:
public sealed class DeepLinkRouteMatcher
: IDeepLinkRouteMatcher
{
public DeepLinkMatch? Match(
DeepLinkRequest request,
IEnumerable<DeepLinkRoute> routes)
{
foreach (var route in routes)
{
var match = TryMatch(
route.Pattern,
request.Path);
if (match is not null)
{
return new DeepLinkMatch(
route,
match);
}
}
return null;
}
private static IReadOnlyDictionary<string, string>? TryMatch(
string pattern,
string path)
{
var patternSegments = Split(pattern);
var pathSegments = Split(path);
if (patternSegments.Length != pathSegments.Length)
return null;
var values = new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < patternSegments.Length; i++)
{
var expected = patternSegments[i];
var actual = pathSegments[i];
if (IsParameter(expected))
{
var parameterName =
expected[1..^1];
values[parameterName] =
Uri.UnescapeDataString(actual);
continue;
}
if (!string.Equals(
expected,
actual,
StringComparison.OrdinalIgnoreCase))
{
return null;
}
}
return values;
}
private static string[] Split(string value)
{
return value
.Trim('/')
.Split(
'/',
StringSplitOptions.RemoveEmptyEntries);
}
private static bool IsParameter(string segment)
{
return segment.StartsWith('{') &&
segment.EndsWith('}') &&
segment.Length > 2;
}
}
Now:
Pattern
/products/{id}
Incoming
/products/42
produces:
id = 42
10. π₯ Extracting Route Parameters
Suppose the route is:
/users/{userId}/orders/{orderId}
and the incoming URI is:
/users/27/orders/915
The matcher returns:
userId = 27
orderId = 915
Those values can then be translated into internal navigation parameters. For example:
var parameters = new Dictionary<string, object>
{
["userId"] = match.RouteValues["userId"],
["orderId"] = match.RouteValues["orderId"]
};
await Shell.Current.GoToAsync(
match.Route.Destination,
parameters);
This keeps external URL parsing outside your page.
11. π Handling Query Parameters
A link might contain:
https://example.com/products/42?campaign=email&source=newsletter
Route parameters describe the resource:
id = 42
Query parameters describe additional context:
campaign = email
source = newsletter
These should remain conceptually separate.
/products/{id}
β
βββ Route value
id = 42
?campaign=email
β
βββ Query value
campaign = email
This distinction becomes useful when deciding which parameters should influence navigation and which should only be used for analytics or presentation.
12. π¦ Creating the Router
Now we can combine the components.
public interface IDeepLinkRouter
{
Task<DeepLinkResult> RouteAsync(
Uri uri,
DeepLinkSource source = DeepLinkSource.Unknown,
CancellationToken cancellationToken = default);
}
Let's define the result:
public enum DeepLinkStatus
{
Navigated,
NotFound,
Invalid,
AuthenticationRequired,
Deferred,
Rejected
}
And:
public sealed record DeepLinkResult(
DeepLinkStatus Status,
string? Message = null);
Returning a structured result is preferable to simply returning bool.
A false tells you almost nothing.
A structured result can distinguish:
Unknown route
Invalid URI
Authentication required
Application not ready
Duplicate request
Navigation failure
13. π§ Implementing the Router
public sealed class DeepLinkRouter : IDeepLinkRouter
{
private readonly IDeepLinkParser _parser;
private readonly IDeepLinkRouteRegistry _registry;
private readonly IDeepLinkRouteMatcher _matcher;
private readonly INavigationService _navigationService;
public DeepLinkRouter(
IDeepLinkParser parser,
IDeepLinkRouteRegistry registry,
IDeepLinkRouteMatcher matcher,
INavigationService navigationService)
{
_parser = parser;
_registry = registry;
_matcher = matcher;
_navigationService = navigationService;
}
public async Task<DeepLinkResult> RouteAsync(
Uri uri,
DeepLinkSource source = DeepLinkSource.Unknown,
CancellationToken cancellationToken = default)
{
var request = _parser.Parse(uri, source);
var match = _matcher.Match(
request,
_registry.Routes);
if (match is null)
{
return new DeepLinkResult(
DeepLinkStatus.NotFound,
"No matching deep link route was found.");
}
await _navigationService.NavigateAsync(
match.Route.Destination,
match.RouteValues,
cancellationToken);
return new DeepLinkResult(
DeepLinkStatus.Navigated);
}
}
At this point we already have something significantly better than direct URI-to-Shell navigation. But production requirements make the router more interesting.
14. π Authentication-Aware Routing
Suppose:
/orders/AB123
requires authentication.
The application receives the link while the user is signed out.
Navigating directly to the order page would be incorrect.
Instead:
Deep Link
β
βΌ
Requires Authentication?
β
Yes
β
βΌ
Authenticated?
β β
Yes No
β β
βΌ βΌ
Navigate Login
β
βΌ
Save Pending Link
Our route already contains:
bool RequiresAuthentication
We can introduce:
public interface IAuthenticationState
{
bool IsAuthenticated { get; }
}
Then:
if (match.Route.RequiresAuthentication &&
!_authenticationState.IsAuthenticated)
{
await _pendingDeepLinkStore.SaveAsync(
request,
cancellationToken);
await _navigationService.NavigateAsync(
"login",
cancellationToken);
return new DeepLinkResult(
DeepLinkStatus.AuthenticationRequired);
}
Now the deep link isn't lost.
15. βΈοΈ Deferred Deep Links
After authentication succeeds, we can continue the original request.
public interface IPendingDeepLinkStore
{
Task SaveAsync(
DeepLinkRequest request,
CancellationToken cancellationToken = default);
Task<DeepLinkRequest?> TakeAsync(
CancellationToken cancellationToken = default);
}
The login flow can then perform:
var pending =
await _pendingDeepLinkStore.TakeAsync();
if (pending is not null)
{
await _deepLinkRouter.RouteAsync(
pending.Uri,
pending.Source);
}
The experience becomes:
User taps:
https://example.com/orders/AB123
β
βΌ
App opens
β
βΌ
Not authenticated
β
βΌ
Login
β
βΌ
Authentication succeeds
β
βΌ
Original deep link resumes
β
βΌ
Order AB123
This is far more polished than dropping the user's original intent after login.
16. π Application Startup and Initialization
Authentication isn't the only reason to defer routing. Imagine the application must initialize:
Configuration
Database
Authentication
Remote settings
Local state
before navigation is safe. A deep link might arrive during cold startup.
OS launches app
β
βββ Deep link arrives
β
βΌ
Application initializing...
Calling Shell navigation immediately can introduce timing problems. Instead, define application readiness:
public interface IApplicationReadiness
{
bool IsReady { get; }
Task WaitUntilReadyAsync(
CancellationToken cancellationToken = default);
}
Then:
await _applicationReadiness.WaitUntilReadyAsync(
cancellationToken);
before performing navigation.
The flow becomes deterministic:
Incoming URI
β
βΌ
Parse
β
βΌ
Wait for Application Ready
β
βΌ
Resolve Route
β
βΌ
Navigate
17. π¦ Preventing Duplicate Deep Link Navigation
Deep links can occasionally arrive through multiple pathways. For example:
Platform callback
Push notification handler
Application activation
You don't want:
/products/42
to navigate twice. A simple execution gate can protect the router:
private readonly SemaphoreSlim _navigationGate =
new(1, 1);
Then:
if (!await _navigationGate.WaitAsync(
0,
cancellationToken))
{
return new DeepLinkResult(
DeepLinkStatus.Rejected,
"Another deep link is currently being processed.");
}
try
{
return await RouteCoreAsync(
uri,
source,
cancellationToken);
}
finally
{
_navigationGate.Release();
}
This prevents overlapping routing operations.
However, it doesn't prevent the same URI from arriving sequentially.
For that, we can introduce deduplication.
18. π Deep Link Deduplication
Store the most recently processed URI:
private string? _lastUri;
private DateTimeOffset _lastProcessedAt;
Then:
private bool IsDuplicate(Uri uri)
{
var now = DateTimeOffset.UtcNow;
var duplicate =
string.Equals(
_lastUri,
uri.AbsoluteUri,
StringComparison.OrdinalIgnoreCase) &&
now - _lastProcessedAt <
TimeSpan.FromSeconds(2);
_lastUri = uri.AbsoluteUri;
_lastProcessedAt = now;
return duplicate;
}
This can prevent accidental double delivery.
Don't make the window excessively large.
A user may legitimately open the same link again later.
19. π‘οΈ Security and Input Validation
Deep links are external input.
Treat them exactly like data arriving from an API request.
Never assume:
The operating system gave me this URI,
therefore it is safe.
A malicious application, website, QR code, message, or crafted URL may invoke your deep link. Validate:
- scheme
- host
- path
- route
- parameter count
- parameter format
- identifier length
- query values
- allowed destinations
For example:
if (!Guid.TryParse(
match.RouteValues["id"],
out var productId))
{
return new DeepLinkResult(
DeepLinkStatus.Invalid,
"Invalid product identifier.");
}
Never dynamically execute arbitrary navigation based solely on user-controlled URI content.
20. π« Don't Expose Arbitrary Shell Routes
This is dangerous:
await Shell.Current.GoToAsync(
uri.AbsolutePath);
because the external URI is controlling internal navigation. Prefer an allowlist:
External URI
β
βΌ
Known Route Registry
β
βββ Match β continue
β
βββ No match β reject
Only routes explicitly registered by the application should be navigable from external links.
21. π§± Route-Specific Validation
Different routes need different validation rules. For example:
/products/{id}
might require:
id = positive integer
while:
/orders/{number}
might require:
number = 6-20 alphanumeric characters
Rather than putting every rule inside the central router, route-specific handlers can own validation.
22. π§© Introducing Route Handlers
Define:
public interface IDeepLinkHandler
{
Task<DeepLinkResult> HandleAsync(
DeepLinkContext context,
CancellationToken cancellationToken = default);
}
Context:
public sealed record DeepLinkContext(
DeepLinkRequest Request,
DeepLinkMatch Match);
Then a product handler:
public sealed class ProductDeepLinkHandler
: IDeepLinkHandler
{
private readonly INavigationService _navigationService;
public ProductDeepLinkHandler(
INavigationService navigationService)
{
_navigationService = navigationService;
}
public async Task<DeepLinkResult> HandleAsync(
DeepLinkContext context,
CancellationToken cancellationToken = default)
{
if (!context.Match.RouteValues.TryGetValue(
"id",
out var value) ||
!int.TryParse(value, out var productId) ||
productId <= 0)
{
return new DeepLinkResult(
DeepLinkStatus.Invalid,
"Invalid product identifier.");
}
await _navigationService.NavigateAsync(
"product-details",
new Dictionary<string, object>
{
["id"] = productId
},
cancellationToken);
return new DeepLinkResult(
DeepLinkStatus.Navigated);
}
}
Now the central router handles infrastructure while handlers understand domain-specific routes.
23. ποΈ Router vs Handler Responsibilities
| Component | Responsibility | | --- | --- | | Platform receiver | Receive URI | | Parser | Normalize URI | | Registry | Declare supported routes | | Matcher | Match URI patterns | | Router | Coordinate routing | | Authentication policy | Protect authenticated routes | | Handler | Route-specific behavior | | Navigation service | Perform internal navigation | | Logger | Diagnostics | | Pending store | Deferred navigation | This separation prevents the router from becoming a 2,000-line class full of if statements.
24. π₯ Route Priorities
Consider:
/products/special
/products/{id}
The URI:
/products/special
matches both structurally if dynamic parameters accept arbitrary strings. Which route wins?
A robust router should define deterministic precedence.
For example:
public sealed record DeepLinkRoute(
string Pattern,
string Destination,
bool RequiresAuthentication = false,
int Priority = 0);
Then:
foreach (var route in routes
.OrderByDescending(x => x.Priority))
{
// Match
}
Define:
new DeepLinkRoute(
"/products/special",
"special-products",
Priority: 100);
new DeepLinkRoute(
"/products/{id}",
"product-details",
Priority: 10);
Now route resolution is explicit.
25. π― Prefer Specific Routes
Another strategy is automatic specificity. For example:
/products/special
contains two static segments.
/products/{id}
contains one static segment and one dynamic segment. The router can score:
Static segment = 10
Dynamic segment = 1
Then:
/products/special = 20
/products/{id} = 11
The most specific route wins.
This is useful when the registry becomes large.
26. π± Custom URI Schemes
A custom scheme might look like:
shaunebu://products/42
One subtle issue is URI interpretation. With:
shaunebu://products/42
products may be interpreted as the host and /42 as the path. Meanwhile:
https://shaunebu.com/products/42
has:
Host = shaunebu.com
Path = /products/42
Your normalization layer should account for this.
You might normalize custom schemes into an application path:
shaunebu://products/42
β
βΌ
/products/42
That lets the rest of your router remain platform- and scheme-independent.
27. π HTTPS App Links
HTTPS links have several advantages. A user can open:
https://example.com/products/42
If the application is installed and properly associated, the operating system can open the app.
If the application isn't available, the same URL can still work as a normal website.
Conceptually:
https://example.com/products/42
β
βΌ
Is app available?
β β
Yes No
β β
βΌ βΌ
App Website
This creates a much better external contract than relying exclusively on custom URI schemes.
28. π€ Android App Links
Android handles deep links through its intent system.
For verified HTTPS App Links, the application declares intent filters for the domains and paths it handles.
A simplified .NET MAUI example might use an attribute on MainActivity:
[IntentFilter(
new[] { Intent.ActionView },
Categories = new[]
{
Intent.CategoryDefault,
Intent.CategoryBrowsable
},
DataScheme = "https",
DataHost = "example.com",
AutoVerify = true)]
public class MainActivity : MauiAppCompatActivity
{
}
For verified Android App Links, domain association also requires a Digital Asset Links file hosted by the website, typically at:
https://example.com/.well-known/assetlinks.json
This proves the relationship between the website and the Android application.
Once Android delivers the URI to MAUI, the platform-specific mechanism should hand it to the same cross-platform router.
Android Intent
β
βΌ
MAUI
β
βΌ
IDeepLinkRouter
The routing architecture should not care whether the URI originated from Android, iOS, a push notification, or an internal test.
29. π iOS Considerations
On Apple platforms, deep linking typically involves URL schemes or associated web links configured through platform-specific application capabilities and metadata.
The important architectural rule remains:
Platform integration
β
βΌ
Extract URI
β
βΌ
Cross-platform router
Avoid duplicating business routing logic in platform projects. Bad:
Platforms/Android
βββ Product routing logic
Platforms/iOS
βββ Product routing logic
Better:
Platforms/Android βββ
β
Platforms/iOS βββββββΌβββΊ IDeepLinkRouter
β
Push Service ββββββββ
The platform layers should only translate platform events into application-level requests.
30. π Shell Navigation
.NET MAUI Shell already provides URI-based internal navigation. For example:
Routing.RegisterRoute(
"product-details",
typeof(ProductDetailsPage));
Then:
await Shell.Current.GoToAsync(
"product-details?id=42");
Our router does not replace Shell. It sits in front of Shell.
External URI
β
βΌ
Deep Link Router
β
βΌ
Internal Route
β
βΌ
Shell
That's the key architectural distinction.
Shell handles application navigation.
The deep link router handles external intent resolution.
31. π¦ Passing Strongly Typed Navigation Data
Instead of manually building query strings:
await Shell.Current.GoToAsync(
$"product-details?id={productId}");
you can use navigation parameters:
var parameters =
new Dictionary<string, object>
{
["id"] = productId
};
await Shell.Current.GoToAsync(
"product-details",
parameters);
This avoids unnecessary string construction and gives your navigation abstraction more control over parameter handling.
32. π§ Creating a Navigation Service
Keep Shell out of the router itself.
public interface INavigationService
{
Task NavigateAsync(
string route,
IReadOnlyDictionary<string, string> parameters,
CancellationToken cancellationToken = default);
Task NavigateAsync(
string route,
CancellationToken cancellationToken = default);
}
Implementation:
public sealed class ShellNavigationService
: INavigationService
{
public Task NavigateAsync(
string route,
IReadOnlyDictionary<string, string> parameters,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var shellParameters =
parameters.ToDictionary(
x => x.Key,
x => (object)x.Value);
return Shell.Current.GoToAsync(
route,
shellParameters);
}
public Task NavigateAsync(
string route,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Shell.Current.GoToAsync(route);
}
}
This makes the deep link router much easier to test.
33. π§ͺ Testing Route Matching
Because our matcher doesn't depend on MAUI UI components, it can be tested as ordinary .NET code. Example:
[Fact]
public void Match_ProductRoute_ExtractsIdentifier()
{
var route = new DeepLinkRoute(
"/products/{id}",
"product-details");
var request = new DeepLinkRequest(
new Uri("https://example.com/products/42"),
"/products/42",
new Dictionary<string, string>(),
DeepLinkSource.AppLink);
var matcher = new DeepLinkRouteMatcher();
var result = matcher.Match(
request,
new[] { route });
Assert.NotNull(result);
Assert.Equal(
"42",
result.RouteValues["id"]);
}
This is one of the major benefits of separating URI parsing from navigation.
34. π§ͺ Testing Unknown Routes
[Fact]
public void Match_UnknownRoute_ReturnsNull()
{
var route = new DeepLinkRoute(
"/products/{id}",
"product-details");
var request = new DeepLinkRequest(
new Uri("https://example.com/unknown/42"),
"/unknown/42",
new Dictionary<string, string>(),
DeepLinkSource.AppLink);
var matcher = new DeepLinkRouteMatcher();
var result = matcher.Match(
request,
new[] { route });
Assert.Null(result);
}
Unknown input should fail predictably.
35. π§ͺ Testing Malformed Parameters
Route matching and parameter validation are separate concerns. This:
/products/not-a-number
may structurally match:
/products/{id}
but the handler should reject it if id must be numeric.
Test both layers.
Matcher
β
βββ Does the URI match the route shape?
Handler
β
βββ Are the values valid for this destination?
This produces cleaner tests and clearer responsibilities.
36. π§ͺ Testing Authentication
A protected route should not navigate directly when the user isn't authenticated. Example expectation:
Input:
/orders/AB123
Authentication:
false
Expected:
AuthenticationRequired
Pending deep link stored
Navigation β Login
Then after authentication:
Pending deep link
β
βΌ
Router
β
βΌ
Order AB123
These flows are much easier to verify when authentication and routing are explicit services.
37. π Logging and Diagnostics
Deep linking failures can be frustrating because the application is often launched from an external context. Structured logging is extremely useful. For example:
_logger.LogInformation(
"Processing deep link {Uri} from {Source}",
uri,
source);
When matched:
_logger.LogInformation(
"Deep link {Uri} matched route {Pattern}",
uri,
match.Route.Pattern);
When rejected:
_logger.LogWarning(
"No route matched deep link {Uri}",
uri);
Be careful not to log sensitive query parameters. A link might contain:
?token=...
?email=...
?code=...
Logging the complete URI blindly can expose sensitive information.
38. π Redacting Sensitive Parameters
Define sensitive names:
private static readonly HashSet<string> SensitiveParameters =
new(StringComparer.OrdinalIgnoreCase)
{
"token",
"access_token",
"code",
"password",
"secret"
};
Then redact before logging:
https://example.com/reset?token=abc123
β
https://example.com/reset?token=[REDACTED]
Observability should never become a data-leak mechanism.
39. π Analytics
Deep links are also useful analytics entry points. You may want to record:
Source
Campaign
Route
Success / failure
Time to destination
Authentication required
Cold start / warm start
Example event:
DeepLinkOpened
{
Route: "/products/{id}",
Source: "AppLink",
Campaign: "summer-sale",
Result: "Navigated"
}
Again, don't send sensitive route values unless your privacy model explicitly permits it.
40. 𧨠Common Mistake: Mixing External and Internal Routes
Avoid assuming:
Public URL == Shell Route
Your website structure is an external contract.
Your Shell hierarchy is an implementation detail.
The router should translate between them.
41. 𧨠Common Mistake: Routing Before Startup Completes
Cold-start deep links can arrive before your application is ready. Don't rely on arbitrary delays:
await Task.Delay(2000);
That's fragile. Use an explicit readiness signal instead:
await _applicationReadiness.WaitUntilReadyAsync(
cancellationToken);
Synchronization should represent actual application state, not guessed timing.
42. 𧨠Common Mistake: Trusting Parameters
Never do:
var id = request.QueryParameters["id"];
await Shell.Current.GoToAsync(
$"admin/{id}");
without validating the value and ensuring that the destination itself is allowed. Deep links are untrusted input.
43. 𧨠Common Mistake: Giant switch Statements
This starts innocently:
switch (uri.AbsolutePath)
{
case "/profile":
...
break;
case "/settings":
...
break;
}
Then:
20 routes
40 routes
Authentication
Feature flags
Parameters
Campaigns
Redirects
Legacy URLs
and eventually the application owns a massive routing method.
A registry + matcher + handlers scales much better.
44. 𧨠Common Mistake: Losing the Link During Login
This is one of the worst UX problems.
User taps order link
β
βΌ
Login required
β
βΌ
Login page
β
βΌ
Home page
The user asked to see an order. Why are they now on the home page? Preserve their intent:
Order link
β
βΌ
Login
β
βΌ
Order
45. 𧨠Common Mistake: Multiple Navigation Requests
A cold start can involve:
Default startup navigation
Authentication navigation
Deep link navigation
Notification navigation
all competing.
The result can become timing-dependent.
Your application should establish navigation precedence.
For example:
1. Critical startup
2. Authentication
3. Pending external intent
4. Default navigation
This turns startup navigation into a deterministic policy rather than a race.
46. π§ Deep Links as Application Commands
A useful mental model is to stop thinking of a deep link as "a URL that opens a page." Think of it as:
External Application Command
For example:
/products/42
means:
OpenProduct(42)
And:
/orders/AB123
means:
OpenOrder("AB123")
That conceptual transformation is powerful.
URI
β
βΌ
Route
β
βΌ
Application Intent
β
βΌ
Policy
β
βΌ
Navigation
Now the architecture isn't tied directly to page names.
47. π Redirects and Legacy Links
Public links often outlive application versions. Suppose the old URL was:
/item/42
but the new public URL is:
/products/42
Instead of breaking old links, add a compatibility route:
new DeepLinkRoute(
"/item/{id}",
"product-details");
Both:
/item/42
/products/42
can resolve to the same internal destination.
This is another major advantage of separating external routes from Shell navigation.
48. π© Feature Flags
Deep links may point to features that aren't enabled. Suppose:
/beta/dashboard
is only available to selected users.
Before routing:
Deep Link
β
βΌ
Feature Enabled?
β β
Yes No
β β
βΌ βΌ
Navigate Fallback
A route handler can consult your feature-management service.
if (!await _featureManager.IsEnabledAsync(
"NewDashboard"))
{
return new DeepLinkResult(
DeepLinkStatus.Rejected,
"The requested feature is unavailable.");
}
Deep linking should never bypass feature authorization or rollout policies.
49. π Authorization Is Different from Authentication
Authentication asks:
Who are you?
Authorization asks:
Can you access this resource?
A user may be logged in but still shouldn't access:
/admin/reports
or:
/orders/another-users-order
The router can establish broad route policy, but resource-level authorization should still occur in the application/service/backend layer.
Never assume that hiding a route provides security.
50. βοΈ Dependency Injection
Register the infrastructure:
builder.Services.AddSingleton<
IDeepLinkRouteRegistry,
DeepLinkRouteRegistry>();
builder.Services.AddSingleton<
IDeepLinkParser,
DeepLinkParser>();
builder.Services.AddSingleton<
IDeepLinkRouteMatcher,
DeepLinkRouteMatcher>();
builder.Services.AddSingleton<
IDeepLinkRouter,
DeepLinkRouter>();
builder.Services.AddSingleton<
INavigationService,
ShellNavigationService>();
builder.Services.AddSingleton<
IPendingDeepLinkStore,
PendingDeepLinkStore>();
The exact lifetimes depend on your architecture.
The route registry is naturally application-wide.
The router often is too, particularly if it owns synchronization or deduplication state.
51. π² Receiving the Link in MAUI
For supported app-link scenarios, .NET MAUI exposes an application-level callback that can be used to receive the URI. For example:
protected override void OnAppLinkRequestReceived(Uri uri)
{
base.OnAppLinkRequestReceived(uri);
_ = HandleDeepLinkAsync(uri);
}
Then:
private async Task HandleDeepLinkAsync(Uri uri)
{
try
{
await _deepLinkRouter.RouteAsync(
uri,
DeepLinkSource.AppLink);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Deep link processing failed.");
}
}
The callback remains thin. That's exactly what we want.
Platform/Application Callback
β
βΌ
Router
not:
Platform Callback
β
βββ Parse
βββ Authenticate
βββ Validate
βββ Query database
βββ Build Shell URI
βββ Navigate
52. ποΈ Complete Production Flow
Our final architecture looks like this:
βββββββββββββββββββββββββββββββββββββββ
β External Source β
β β
β Website / Email / QR / Push / SMS β
ββββββββββββββββββββ¬βββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Operating System β
β β
β Android Intent / Apple URL handling β
ββββββββββββββββββββ¬βββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Platform Integration β
ββββββββββββββββββββ¬βββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Deep Link Parser β
β β
β Normalize scheme / host / path β
β Parse query β
ββββββββββββββββββββ¬βββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Route Registry + Matcher β
β β
β /products/{id} β
β /orders/{number} β
β /profile β
ββββββββββββββββββββ¬βββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Routing Policies β
β β
β App ready? β
β Duplicate? β
β Authenticated? β
β Feature enabled? β
β Parameters valid? β
ββββββββββββββββββββ¬βββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Deep Link Handler β
β β
β Convert URI β Application Intent β
ββββββββββββββββββββ¬βββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Navigation Service β
ββββββββββββββββββββ¬βββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β .NET MAUI Shell β
βββββββββββββββββββββββββββββββββββββββ
The platform integration remains thin.
The router remains testable.
The navigation system remains replaceable.
And external URLs remain independent from your internal page structure.
53. π Simple vs Robust Deep Linking
| Capability | Direct GoToAsync | Deep Link Router |
|---|---|---|
| Static links | β | β |
| Dynamic parameters | β οΈ Manual | β |
| Query parsing | β οΈ Manual | β |
| Route validation | β | β |
| Authentication | β οΈ Manual | β |
| Deferred navigation | β | β |
| Startup readiness | β | β |
| Duplicate protection | β | β |
| Route priorities | β | β |
| Legacy redirects | β οΈ | β |
| Feature policies | β | β |
| Structured results | β | β |
| Unit testing | Difficult | β |
| Diagnostics | Limited | β |
| External/internal isolation | β | β |
For a small application with two links, the simple approach may be perfectly adequate.
For an application where links are part of the product experience, the routing layer quickly becomes valuable.
54. π Best Practices
When implementing deep linking in .NET MAUI:
- π Treat deep links as external application input.
- πΊοΈ Separate public URLs from internal Shell routes.
- π Maintain an explicit allowlisted route registry.
- π Normalize URIs before matching them.
- π§© Support dynamic route parameters intentionally.
- π‘οΈ Validate every external parameter.
- π Distinguish authentication from authorization.
- βΈοΈ Preserve deep links across authentication flows.
- π Wait for real application readiness during cold startup.
- π¦ Prevent overlapping navigation operations.
- π Deduplicate accidental repeated delivery.
- π§ Keep platform-specific code thin.
- π§© Use route-specific handlers as complexity grows.
- π Add structured diagnostics.
- π Redact sensitive URI values from logs.
- π© Respect feature flags and rollout policies.
- π Maintain compatibility for important legacy URLs.
- π§ͺ Unit-test parsing and route resolution independently.
- π Prefer verified web links when links also represent web content.
- ποΈ Treat deep linking as part of application architecture, not just navigation.
π― Conclusion
Deep linking is easy to demonstrate and surprisingly difficult to make robust. The simplest implementation can often be written in a few lines:
await Shell.Current.GoToAsync(route);
But real applications must deal with much more than navigation.
They must handle:
Cold startup
Authentication
Authorization
Malformed URLs
Dynamic parameters
Query parameters
Duplicate delivery
Legacy URLs
Feature availability
Navigation races
Platform differences
External security boundaries
That's why a deep link should not be treated simply as an internal navigation route. It should be treated as an external application request. A robust architecture therefore transforms:
External URI
into:
Validated Application Intent
before navigation occurs. The resulting pipeline is straightforward:
Receive
β
βΌ
Normalize
β
βΌ
Match
β
βΌ
Validate
β
βΌ
Apply Policies
β
βΌ
Resolve Intent
β
βΌ
Navigate
Once those responsibilities are separated, deep linking becomes easier to evolve, test, secure, and diagnose.
Your website can change.
Your Shell hierarchy can change.
Your authentication flow can change.
Pages can move.
Features can be replaced.
But links that users already have in emails, QR codes, notifications, websites, and bookmarks can continue to resolve through the same stable routing contract.
And that's the real goal of a robust deep link architecture: not merely opening the right page today, but continuing to resolve user intent correctly as the application evolves. ππ
π References
- Microsoft Learn β Android App Links in .NET MAUI: https://learn.microsoft.com/dotnet/maui/android/app-links
- Microsoft Learn β .NET MAUI Shell: https://learn.microsoft.com/dotnet/maui/fundamentals/shell/
- Microsoft Learn β .NET MAUI Shell Navigation: https://learn.microsoft.com/dotnet/maui/fundamentals/shell/navigation
- Microsoft Learn β .NET MAUI Launcher: https://learn.microsoft.com/dotnet/maui/platform-integration/appmodel/launcher
- Android Developers β App Links: https://developer.android.com/training/app-links
- Apple Developer β Supporting Associated Domains: https://developer.apple.com/documentation/xcode/supporting-associated-domains
Was this useful?
Sign in to react. Guest comments are still welcome.




Comments (0)
No approved comments yet.