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

  1. What Is Deep Linking?
  2. Deep Links vs App Links
  3. Why Direct Navigation Becomes Fragile
  4. Designing the Architecture
  5. Defining a Deep Link Request
  6. Building a Route Definition
  7. Creating a Route Registry
  8. Parsing Incoming URIs
  9. Matching Dynamic Route Segments
  10. Extracting Route Parameters
  11. Handling Query Parameters
  12. Creating the Router
  13. Translating External Routes into Shell Routes
  14. Authentication-Aware Routing
  15. Deferred Deep Links
  16. Application Startup and Initialization
  17. Preventing Duplicate Deep Link Navigation
  18. Security and Input Validation
  19. Handling Unknown Routes
  20. Route Priorities
  21. Custom URI Schemes
  22. HTTPS App Links
  23. Android App Links
  24. iOS Considerations
  25. Navigation Context
  26. Route Handlers
  27. Testing the Router
  28. Logging and Diagnostics
  29. Common Mistakes
  30. Production Architecture
  31. Best Practices
  32. Conclusion
  33. 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.


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:

  1. πŸ”— Treat deep links as external application input.
  2. πŸ—ΊοΈ Separate public URLs from internal Shell routes.
  3. πŸ“š Maintain an explicit allowlisted route registry.
  4. πŸ” Normalize URIs before matching them.
  5. 🧩 Support dynamic route parameters intentionally.
  6. πŸ›‘οΈ Validate every external parameter.
  7. πŸ” Distinguish authentication from authorization.
  8. ⏸️ Preserve deep links across authentication flows.
  9. πŸš€ Wait for real application readiness during cold startup.
  10. 🚦 Prevent overlapping navigation operations.
  11. πŸ” Deduplicate accidental repeated delivery.
  12. 🧭 Keep platform-specific code thin.
  13. 🧩 Use route-specific handlers as complexity grows.
  14. πŸ“Š Add structured diagnostics.
  15. πŸ”’ Redact sensitive URI values from logs.
  16. 🚩 Respect feature flags and rollout policies.
  17. πŸ”€ Maintain compatibility for important legacy URLs.
  18. πŸ§ͺ Unit-test parsing and route resolution independently.
  19. 🌐 Prefer verified web links when links also represent web content.
  20. πŸ—οΈ 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

Was this useful?

Comments (0)

Leave a comment

Submit for moderation
An unhandled error has occurred. Reload πŸ—™