Why Deep Linking Matters for Mobile Apps
Deep linking lets users tap a URL — in an email, a text, a social post, or even a QR code — and land directly on a specific screen inside your app. Without it, they'd have to open the app manually and hunt for the right content themselves. If you're building an e-commerce app, a content platform, or really anything that shares URLs externally, deep linking isn't optional. It's table stakes for engagement and conversion.
In .NET MAUI 10, you can wire up deep linking on both Android and iOS using platform-specific configuration — Android App Links and Apple Universal Links — then route incoming URLs through Shell navigation for a unified, cross-platform experience.
So, let's walk through every step: from hosting server-side verification files to handling navigation in your app, with working code targeting .NET 10.
Deep Linking vs. Custom URL Schemes: Which Should You Pick?
Before jumping into implementation, it's worth understanding the two main approaches:
- Universal Links (iOS) / App Links (Android): Use standard
https://URLs. The OS verifies that your app is authorized to handle the domain through a server-hosted verification file. If the app isn't installed, the URL just opens in the browser like a normal webpage. This is the recommended approach. - Custom URL Schemes: Use a custom protocol like
myapp://. No server-side verification needed, but any app can register the same scheme — hello, conflicts. And there's no graceful fallback if the app isn't installed; the link simply fails.
For production apps, go with Universal Links and App Links. They provide a verified, secure connection between your domain and your app. Custom URL schemes still have their place — they're fine for development, testing, or internal-only apps where you control all the clients.
Project Setup
This guide assumes you've got a .NET MAUI 10 project using Shell navigation. Starting from scratch? Create a new project:
dotnet new maui -n DeepLinkDemo
cd DeepLinkDemo
Make sure your .csproj targets .NET 10:
<TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
For the examples here, we'll use the domain example.com and build a product detail deep link at https://example.com/products/{id}.
Setting Up Android App Links
Step 1: Create and Host the Digital Asset Links File
Android verifies your app's ownership of a domain by fetching a JSON file from your web server. Create a file named assetlinks.json and host it at https://example.com/.well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.deeplinkdemo",
"sha256_cert_fingerprints": [
"AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90"
]
}
}
]
Replace package_name with your actual Android package name and sha256_cert_fingerprints with your signing certificate's SHA-256 fingerprint. You can grab the fingerprint using:
keytool -list -v -keystore your-keystore.jks -alias your-alias
The file must be served over HTTPS with the content type application/json. If it's inaccessible or malformed, Android won't verify the link — and you'll be left scratching your head wondering why nothing works.
Step 2: Configure the Intent Filter in MainActivity
Open Platforms/Android/MainActivity.cs and add an IntentFilter attribute to the MainActivity class:
using Android.App;
using Android.Content;
using Android.Content.PM;
namespace DeepLinkDemo;
[Activity(
Theme = "@style/Maui.SplashTheme",
MainLauncher = true,
LaunchMode = LaunchMode.SingleTop,
Exported = true,
ConfigurationChanges = ConfigChanges.ScreenSize
| ConfigChanges.Orientation
| ConfigChanges.UiMode
| ConfigChanges.ScreenLayout
| ConfigChanges.SmallestScreenSize
| ConfigChanges.Density)]
[IntentFilter(
new[] { Intent.ActionView },
AutoVerify = true,
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "https",
DataHost = "example.com",
DataPathPrefix = "/products")]
public class MainActivity : MauiAppCompatActivity
{
}
A few things to note here:
Exported = trueis required for activities that handle external intents. Without it, Android silently ignores incoming deep links. This one has bitten me more than once.LaunchMode = LaunchMode.SingleTopprevents the system from creating a new activity instance when the app is already running. Instead, the existing instance receives the intent throughOnNewIntent.AutoVerify = truetells Android to check theassetlinks.jsonfile at install time. If verification fails, Android treats the link as a regular browser URL.DataPathPrefix = "/products"scopes handling to only URLs under/products. You can add moreIntentFilterattributes for other paths.
Step 3: Handle the Incoming Intent
Override OnNewIntent in MainActivity to handle deep links that arrive while the app is already running, and process the intent on creation too:
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
HandleIntent(Intent);
}
protected override void OnNewIntent(Intent? intent)
{
base.OnNewIntent(intent);
HandleIntent(intent);
}
private void HandleIntent(Intent? intent)
{
if (intent?.Action == Intent.ActionView && intent.Data is not null)
{
var url = intent.Data.ToString();
if (!string.IsNullOrEmpty(url))
{
App.HandleDeepLink(new Uri(url));
}
}
}
Setting Up Apple Universal Links (iOS and Mac Catalyst)
Step 1: Create and Host the Apple App Site Association File
Apple verifies domain ownership through an apple-app-site-association (AASA) file. Create it and host it at https://example.com/.well-known/apple-app-site-association:
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": ["TEAMID.com.example.deeplinkdemo"],
"components": [
{
"/": "/products/*",
"comment": "Match all product detail URLs"
}
]
}
]
}
}
Replace TEAMID with your Apple Developer Team ID and com.example.deeplinkdemo with your bundle identifier. The file must be served as application/json over HTTPS with no redirects.
Here's something that trips people up: since iOS 14 and macOS 11, Apple fetches AASA files through its own CDN rather than directly from your server. After updating the file, it can take up to 24 hours for Apple's CDN to refresh. During development, you can add ?mode=developer to your associated domain to bypass the CDN cache. Trust me, this will save you a lot of "why isn't it working?" moments.
Step 2: Add the Associated Domains Entitlement
Create or update the entitlements file at Platforms/iOS/Entitlements.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
</array>
</dict>
</plist>
For debug builds, use applinks:example.com?mode=developer to bypass Apple's CDN caching.
Reference the entitlements file in your .csproj:
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">
<CodesignEntitlements>Platforms/iOS/Entitlements.plist</CodesignEntitlements>
</PropertyGroup>
Step 3: Enable Associated Domains in Your Apple Developer Account
Log in to the Apple Developer Portal, navigate to your App ID, and enable the Associated Domains capability. This step is easy to overlook, and honestly, it's one of the most common reasons universal links silently fail.
Step 4: Handle Incoming Universal Links
Now wire up iOS lifecycle events in MauiProgram.cs to capture incoming universal links. This is where things get a bit verbose (fair warning):
using Microsoft.Maui.LifecycleEvents;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureLifecycleEvents(events =>
{
#if IOS || MACCATALYST
events.AddiOS(ios =>
{
// Handle links when the app is launched from a cold start
ios.FinishedLaunching((app, data) =>
{
if (app.UserActivity is not null)
HandleUserActivity(app.UserActivity);
return true;
});
// Handle links when the app is already running
ios.ContinueUserActivity((app, userActivity, handler) =>
{
HandleUserActivity(userActivity);
return true;
});
// iOS 13+ scene-based lifecycle
if (OperatingSystem.IsIOSVersionAtLeast(13)
|| OperatingSystem.IsMacCatalystVersionAtLeast(13))
{
ios.SceneWillConnect((scene, session, options) =>
{
var activity = options.UserActivities
.ToArray()
.FirstOrDefault(a =>
a.ActivityType ==
Foundation.NSUserActivityType.BrowsingWeb);
if (activity is not null)
HandleUserActivity(activity);
});
ios.SceneContinueUserActivity((scene, userActivity) =>
{
HandleUserActivity(userActivity);
});
}
});
#endif
});
return builder.Build();
}
#if IOS || MACCATALYST
private static void HandleUserActivity(Foundation.NSUserActivity? activity)
{
if (activity?.ActivityType == Foundation.NSUserActivityType.BrowsingWeb
&& activity.WebPageUrl is not null)
{
var url = activity.WebPageUrl.ToString();
if (!string.IsNullOrEmpty(url))
{
App.HandleDeepLink(new Uri(url));
}
}
}
#endif
}
The code above handles four distinct lifecycle scenarios: cold start via FinishedLaunching, warm resume via ContinueUserActivity, and the equivalent scene-based callbacks for iOS 13+ and Mac Catalyst. You really do need all four — otherwise, universal links will work in some situations but mysteriously fail in others.
Building a Cross-Platform Deep Link Router
Both the Android and iOS handlers call App.HandleDeepLink. Let's implement this central routing method in App.xaml.cs so incoming URLs get parsed once and routed through Shell navigation:
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new AppShell());
}
public static async void HandleDeepLink(Uri uri)
{
// Wait until Shell is ready
if (Shell.Current is null)
{
// Retry after Shell initialization
await Task.Delay(500);
if (Shell.Current is null)
return;
}
await MainThread.InvokeOnMainThreadAsync(async () =>
{
var path = uri.AbsolutePath.TrimEnd('/');
var route = MatchRoute(path, uri.Query);
if (route is not null)
{
await Shell.Current.GoToAsync(route);
}
});
}
private static string? MatchRoute(string path, string query)
{
// /products/123 -> product detail page
if (path.StartsWith("/products/") && path.Length > "/products/".Length)
{
var productId = path["/products/".Length..];
return $"//products/detail?id={Uri.EscapeDataString(productId)}";
}
// /orders/456 -> order detail page
if (path.StartsWith("/orders/") && path.Length > "/orders/".Length)
{
var orderId = path["/orders/".Length..];
return $"//orders/detail?id={Uri.EscapeDataString(orderId)}";
}
// /promo?code=SAVE20 -> promotions page
if (path == "/promo")
{
var queryParams = System.Web.HttpUtility.ParseQueryString(query);
var code = queryParams["code"];
if (!string.IsNullOrEmpty(code))
return $"//promotions?code={Uri.EscapeDataString(code)}";
}
return null;
}
}
This router centralizes all deep link URL parsing in one place, which makes it easy to add new routes as your app grows. The Uri.EscapeDataString calls protect against injection of unexpected Shell navigation segments — a small but important detail.
Configuring Shell Routes for Deep Link Targets
Register the routes in your AppShell.xaml and code-behind so Shell knows how to resolve the navigation paths:
<!-- AppShell.xaml -->
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:DeepLinkDemo.Views"
x:Class="DeepLinkDemo.AppShell">
<TabBar>
<ShellContent Title="Home"
Route="home"
ContentTemplate="{DataTemplate views:HomePage}" />
<ShellContent Title="Products"
Route="products"
ContentTemplate="{DataTemplate views:ProductsPage}" />
<ShellContent Title="Orders"
Route="orders"
ContentTemplate="{DataTemplate views:OrdersPage}" />
</TabBar>
</Shell>
// AppShell.xaml.cs
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
// Register detail routes that are not in the visual hierarchy
Routing.RegisterRoute("products/detail", typeof(ProductDetailPage));
Routing.RegisterRoute("orders/detail", typeof(OrderDetailPage));
Routing.RegisterRoute("promotions", typeof(PromotionsPage));
}
}
Receiving Parameters with IQueryAttributable
The recommended way to receive navigation parameters in .NET MAUI 10 is through the IQueryAttributable interface. This approach is trim-safe and compatible with NativeAOT, which matters a lot if you're optimizing for startup performance. The older [QueryProperty] attribute was deprecated for trimmed apps, so don't use that.
public class ProductDetailViewModel : ObservableObject, IQueryAttributable
{
[ObservableProperty]
private string productId = string.Empty;
[ObservableProperty]
private string productName = string.Empty;
[ObservableProperty]
private bool isLoading;
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("id", out var idValue))
{
ProductId = Uri.UnescapeDataString(idValue?.ToString() ?? string.Empty);
_ = LoadProductAsync();
}
}
private async Task LoadProductAsync()
{
IsLoading = true;
try
{
// Fetch product data from your API or local database
var product = await ProductService.GetByIdAsync(ProductId);
if (product is not null)
{
ProductName = product.Name;
}
}
finally
{
IsLoading = false;
}
}
}
ApplyQueryAttributes gets called automatically by Shell after navigation. It receives all query parameters as a dictionary, giving you full control over parsing and validation.
Adding Custom URL Scheme Support (Optional)
If you also need custom URL schemes — say, for app-to-app communication or just easier local testing — you can add that alongside your verified links.
Android Custom Scheme
Add a second IntentFilter to MainActivity.cs:
[IntentFilter(
new[] { Intent.ActionView },
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "deeplinkdemo",
DataHost = "")]
public class MainActivity : MauiAppCompatActivity { }
iOS Custom Scheme
Add the URL type to your Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>deeplinkdemo</string>
</array>
<key>CFBundleURLName</key>
<string>com.example.deeplinkdemo</string>
</dict>
</array>
Then handle the custom scheme URL in the iOS lifecycle by subscribing to the OpenUrl event in MauiProgram.cs:
ios.OpenUrl((app, url, options) =>
{
App.HandleDeepLink(new Uri(url.ToString()));
return true;
});
Your existing App.HandleDeepLink router will handle both HTTPS and custom scheme URLs since it operates on the URL path and query components. Nice and clean.
Testing Deep Links
Testing on Android
Use adb to simulate a deep link from the command line — no need to actually send yourself URLs:
# Test an App Link (HTTPS)
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://example.com/products/42"
# Test a custom scheme
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "deeplinkdemo://products/42"
You can also check App Links verification status:
# Check verification state (Android 12+)
adb shell pm get-app-links com.example.deeplinkdemo
# Re-trigger verification
adb shell pm verify-app-links --re-verify com.example.deeplinkdemo
Testing on iOS
On a physical iOS device, open Safari and navigate to your deep link URL. If universal links are configured correctly, a Smart App Banner or direct redirect should appear. You can also paste the URL in the Notes app and tap it.
For iOS Simulator testing, use xcrun:
xcrun simctl openurl booted "https://example.com/products/42"
To verify your AASA file is accessible to Apple's CDN, check this URL in a browser:
https://app-site-association.cdn-apple.com/a/v1/example.com
On a physical device, you can also go to Settings → Developer → Universal Links → Diagnostics to test whether your domain is properly associated.
Testing Tips and Common Gotchas
I've spent way too many hours debugging deep linking issues, so here are the things that tend to catch people off guard:
- iOS universal links don't work when typed directly into Safari's address bar. They have to be activated from another context — a link in Notes, Mail, Messages, or another app. This is by design, but it's genuinely confusing the first time you encounter it.
- Android App Links verification requires HTTPS. HTTP-only domains won't pass verification, and your app won't become the default handler.
- Clear the Android App Links cache if you update
assetlinks.json— runadb shell pm clear com.android.statementserviceand reinstall the app. - Apple CDN caching can delay AASA changes by up to 24 hours. During development, use the
?mode=developersuffix on your associated domain entitlement. Seriously, don't skip this.
Handling Edge Cases
Deep Link Arrives Before Shell Is Ready
When the app is cold-started via a deep link, Shell may not be initialized yet when your intent handler fires. The HandleDeepLink method we built earlier has a basic delay-and-retry for this, but for something more robust, you can queue the URI and process it from AppShell.OnNavigated:
public partial class App : Application
{
private static Uri? _pendingDeepLink;
public static void HandleDeepLink(Uri uri)
{
if (Shell.Current is null)
{
_pendingDeepLink = uri;
return;
}
NavigateToDeepLink(uri);
}
public static void ProcessPendingDeepLink()
{
if (_pendingDeepLink is not null)
{
var uri = _pendingDeepLink;
_pendingDeepLink = null;
NavigateToDeepLink(uri);
}
}
private static async void NavigateToDeepLink(Uri uri)
{
await MainThread.InvokeOnMainThreadAsync(async () =>
{
var route = MatchRoute(uri.AbsolutePath.TrimEnd('/'), uri.Query);
if (route is not null)
await Shell.Current.GoToAsync(route);
});
}
}
// In AppShell.xaml.cs
protected override void OnNavigated(ShellNavigatedEventArgs args)
{
base.OnNavigated(args);
App.ProcessPendingDeepLink();
}
This pattern is much more reliable than the delay approach. The deep link gets stored until Shell is actually ready, then fires on the first navigation event.
Handling Invalid or Malicious URLs
Always validate incoming deep link parameters before using them in API calls or database queries. You don't want someone crafting a URL that breaks your app (or worse):
private static string? MatchRoute(string path, string query)
{
if (path.StartsWith("/products/") && path.Length > "/products/".Length)
{
var productId = path["/products/".Length..];
// Validate the product ID format
if (productId.Length > 50 || !productId.All(c => char.IsLetterOrDigit(c) || c == '-'))
return null;
return $"//products/detail?id={Uri.EscapeDataString(productId)}";
}
return null;
}
Supporting Multiple Domains
If your app needs to handle links from multiple domains (production and staging, for example), add separate entries to your configuration files:
// Android: Add multiple IntentFilter attributes
[IntentFilter(
new[] { Intent.ActionView },
AutoVerify = true,
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "https",
DataHost = "example.com",
DataPathPrefix = "/products")]
[IntentFilter(
new[] { Intent.ActionView },
AutoVerify = true,
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "https",
DataHost = "staging.example.com",
DataPathPrefix = "/products")]
public class MainActivity : MauiAppCompatActivity { }
<!-- iOS: Add multiple associated domains -->
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
<string>applinks:staging.example.com</string>
</array>
Debugging Checklist
If your deep links aren't working, run through this list before you start pulling your hair out:
- Android: Is
assetlinks.jsonaccessible? Openhttps://example.com/.well-known/assetlinks.jsonin a browser and verify the JSON is valid and the SHA-256 fingerprint matches your signing key. - Android: Is
Exported = trueset onMainActivity? Without it, Android silently blocks incoming intents. - Android: Is
AutoVerify = trueon theIntentFilter? Without auto-verify, Android shows a disambiguation dialog instead of launching your app directly. - iOS: Is the AASA file at the correct path? It must be at
/.well-known/apple-app-site-association— no file extension, served asapplication/json. - iOS: Does the
appIDsarray match your Team ID and Bundle ID exactly? The format isTEAMID.com.example.app. - iOS: Is the Associated Domains capability enabled in the Apple Developer Portal for your App ID?
- iOS: Are you testing correctly? Remember — typing a universal link directly in Safari won't trigger it. Tap it from Notes, Mail, or another app.
- Both: Is Shell fully initialized before calling
GoToAsync? Use the pending deep link pattern described above.
Frequently Asked Questions
What's the difference between deep linking and universal links in .NET MAUI?
Deep linking is the general concept of opening a specific screen in a mobile app via a URL. Universal Links (iOS) and App Links (Android) are platform-specific implementations that use verified https:// URLs. Custom URL schemes (like myapp://) are another form of deep linking but lack domain verification and graceful fallback behavior.
How do I test deep links in the iOS Simulator?
Use xcrun simctl openurl booted "https://yourdomain.com/path" from Terminal. On a physical device, paste the URL in the Notes app and tap it — don't type it directly in Safari, as iOS won't trigger universal links from the address bar. You can also validate your AASA file through Apple's CDN at https://app-site-association.cdn-apple.com/a/v1/yourdomain.com.
Why are my Android App Links not working after updating assetlinks.json?
Android caches the verification result. After updating assetlinks.json, clear the cache with adb shell pm clear com.android.statementservice, then reinstall the app or run adb shell pm verify-app-links --re-verify your.package.name on Android 12+ to force re-verification.
Can I use deep linking with .NET MAUI Blazor Hybrid apps?
Yes! The platform-specific setup (intent filters and entitlements) is identical. The difference is in routing: instead of Shell.Current.GoToAsync, you'd navigate to a Blazor route using NavigationManager. The App.HandleDeepLink method can be adapted to call into your Blazor navigation instead of Shell.
Do I need a web server for deep linking in .NET MAUI?
For Universal Links and App Links — yes, you need to host a verification file (apple-app-site-association for iOS, assetlinks.json for Android) on an HTTPS domain you control. If that's not an option, custom URL schemes work without a server, though you lose domain verification and browser fallback.