All Articles
CategoryFlutter
Reading Time
14 min read
Published
2025-12-17
Word Count
3,241words

Grab a coffee — this one is a deep dive!

Flutter Deep Links: Universal Links & App Links Guide

Summary

Flutter deep link setup: we walk through iOS Universal Links (AASA + entitlement), Android App Links (assetlinks.json + intent filter), and Flutter route mapping step by step with official sources.

  • Custom scheme, Android App Links, and iOS Universal Links are three separate mechanisms; reliable routing in production only comes from App Links/Universal Links.
  • On iOS: an applinks:<domain> entitlement + /.well-known/apple-app-site-association file; the file can't contain a 301/302 redirect and is tested with swcutil dl/swcutil verify.
  • On Android: an android:autoVerify="true" intent-filter + /.well-known/assetlinks.json file; verification runs asynchronously and needs at least 20 seconds to complete.
  • On the Flutter side, listen to both initialRoute and pushRoute/RouteInformationParser — iOS and Android use a different signal order on cold start.
Flutter Deep Links: Universal Links & App Links Guide

Flutter deep link setup usually starts with "I tapped the link, the app didn't open" — and the root cause is often not the code but the platform-side verification files. This guide separates iOS Universal Links, Android App Links, and their common ancestor, the custom URL scheme, and sets up each one's server-side requirements plus Flutter's route-mapping mechanism with real configuration files.

💡 Pro Tip: Before testing a deep link, always run DevTools' Deep Links validator first — it catches missing files/signature problems on both Android and iOS before you even build the app.

Table of Contents

Much of the confusion around deep linking comes from talking about three different mechanisms as if they were one concept.

Custom URL scheme (like myapp://detail/42) requires no OS-level verification; you just declare the scheme in Info.plist/AndroidManifest.xml, and any app can register the same scheme. That's why it's not a reliable routing mechanism on its own in production — it carries collision risk and can't directly capture an https:// link tapped in a browser.

Android App Links builds a verified bond between the site and the app, opening the deep link directly in the app without showing the user a "which app should open this" dialog. Per the official definition: "Android App Links is an enhanced deep linking capability that verifies deep links to your own website by establishing a trusted association between your app and your website." Unverified classic deep links are subject to the system's disambiguation dialog instead. App Links is supported on Android 6.0+ with Google services present (source: developer.android.com/training/app-links).

iOS Universal Links does the same job on Apple's side: https:// links, if they match a verification file on the server, are routed directly into the app without opening Safari. Both share one trait: the verification file lives on the server, and an entitlement/manifest entry on the app side points to it. I don't drop the custom scheme entirely — since it's quickly triggered in development with xcrun simctl openurl or adb shell am start -a android.intent.action.VIEW -d "myapp://detail/42", it's a handy fallback for testing the Flutter route-mapping code before the server-side AASA/assetlinks.json goes live. In production, links sent to users should always be https://; the custom scheme is only for development/testing, or closed, controlled channels like a push-notification payload.

The next two sections show the exact schema of these verification files.

iOS: entitlement + apple-app-site-association + server rules

On iOS, three pieces lock together: the Associated Domains entitlement in Xcode, the AASA file on the server, and the HTTP behavior of the server serving the AASA file.

The entitlement format is stated in Apple's official TN3155 note: the Associated Domains capability must contain applinks, "in the form of: applinks:<fully qualified domain>"; the note also mentions wildcard matching for "subdomains that are matched with the wildcard applinks:*.example.com." So a single domain, or multiple subdomains via a wildcard, can be covered.

xml
1<!-- ios/Runner/Runner.entitlements -->
2<key>com.apple.developer.associated-domains</key>
3<array>
4 <string>applinks:example.com</string>
5 <string>applinks:*.example.com</string>
6</array>

The AASA file must live at a fixed path: as TN3155 puts it, "an AASA file should be hosted at: https://example.com/.well-known/apple-app-site-association"; the note also adds: "Each specific subdomain in your applinks should have its own matching AASA file path."

json
1{
2 "applinks": {
3 "details": [
4 {
5 "appIDs": ["TEAMID.com.example.app"],
6 "components": [{ "/": "/detail/*", "comment": "Product detail page" }]
7 }
8 ]
9 }
10}

The most commonly missed rule: the AASA response must NOT contain a 301/302 HTTP redirect. As TN3155 states: "If the response contains a 301 or 302 HTTP status code... is not supported." Instead, host the file separately at every domain and subdomain included in applinks. To cache it successfully, Apple's CDN requires "a domain that is available to all IP addresses and ranges, does not redirect, and is not blocked by access policies"; for a quick test, bypass the CDN with ?mode=developer (alternate mode) to fetch the file directly.

Two commands take care of verification:

bash
1# AASA download test (as Apple sees it)
2sudo swcutil dl -d example.com
3 
4# Test whether a specific path matches the entitlement
5sudo swcutil verify -d example.com -j /path/to/aasa.json -u https://example.com/detail/42

To check the approval status on a device, you need to pull a sysdiagnose and search for the App ID in swcutil_show.txt — this is a system-level record you won't see in the Xcode console.

Android: assetlinks.json + intent filter + signing fingerprint

On Android, the verification file is named assetlinks.json and three fields are required: package_name (the application ID from build.gradle), sha256_cert_fingerprints (the SHA256 fingerprint of the signing certificate — multiple values are supported), and relation: ["delegate_permission/common.handle_all_urls"].

json
1[
2 {
3 "relation": ["delegate_permission/common.handle_all_urls"],
4 "target": {
5 "namespace": "android_app",
6 "package_name": "com.example.app",
7 "sha256_cert_fingerprints": [
8 "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
9 ]
10 }
11 }
12]

The fingerprint is generated from the signing key with keytool:

bash
1keytool -list -v -keystore my-release-key.keystore

With Play App Signing you don't need to hand-generate the JSON snippet — Play Console → Release → Setup → App signing has a ready-made one. assetlinks.json must be reachable over HTTPS with application/json content-type and no redirect (no 301/302); with multiple host domains, publish the file separately on each one (source: developer.android.com/training/app-links/configure-assetlinks).

On the manifest side, the android:autoVerify="true" flag must be present on at least one intent-filter:

xml
1<intent-filter android:autoVerify="true">
2 <action android:name="android.intent.action.VIEW" />
3 <category android:name="android.intent.category.DEFAULT" />
4 <category android:name="android.intent.category.BROWSABLE" />
5 <data android:scheme="https" android:host="example.com" />
6</intent-filter>

Installing on Android 6.0 (API 23) or above triggers the system to auto-verify the hosts associated with URLs; it only examines intent filters with the VIEW action + BROWSABLE/DEFAULT categories + http/https scheme. It queries https://hostname/.well-known/assetlinks.json for every unique host, and the async verification requires waiting at least 20 seconds — testing before this window elapses and concluding "it doesn't work" is a common misdiagnosis.

Verification status is checked with this command:

bash
1adb shell pm get-app-links com.example.app

Successful domains show as verified; states like none, legacy_failure, or 1024+ indicate a problem (source: developer.android.com/training/app-links/verify-android-applinks).

Once server-side verification is done, the ball is in Flutter's court. Flutter reflects the deep link's URL onto the screen via named routes (the routes parameter or onGenerateRoute) or the Router widget. The official guide no longer recommends named routes for most apps: "Named routes are no longer recommended for most applications." — so for a new setup, prefer a Router/RouteInformationParser-based approach.

dart
1class AppRouteInformationParser extends RouteInformationParser<AppRoutePath> {
2 @override
3 Future<AppRoutePath> parseRouteInformation(
4 RouteInformation routeInformation,
5 ) async {
6 final uri = routeInformation.uri;
7 if (uri.pathSegments.length == 2 && uri.pathSegments.first == 'detail') {
8 final id = int.tryParse(uri.pathSegments[1]);
9 if (id != null) return AppRoutePath.detail(id);
10 }
11 return AppRoutePath.home();
12 }
13}

To turn off Flutter's default deep-link handler (e.g. because you'll handle the link with your own native code), set FlutterDeepLinkingEnabled to false in Info.plist on iOS, and the flutter_deeplinking_enabled meta-data to false in AndroidManifest.xml on Android. Deep linking has been on by default since Flutter 3.27; before that you had to manually add flutter_deeplinking_enabled with a value of true (source: docs.flutter.dev/cookbook/navigation/set-up-app-links).

If you haven't yet migrated an older project to the Router widget, you can achieve the same result with named routes — you just need to manually parse dynamic path parameters (like /detail/42) inside onGenerateRoute:

dart
1MaterialApp(
2 onGenerateRoute: (settings) {
3 final uri = Uri.parse(settings.name ?? '/');
4 if (uri.pathSegments.length == 2 && uri.pathSegments.first == 'detail') {
5 final id = int.tryParse(uri.pathSegments[1]);
6 if (id != null) {
7 return MaterialPageRoute(builder: (_) => DetailPage(id: id));
8 }
9 }
10 return MaterialPageRoute(builder: (_) => const HomePage());
11 },
12)

Both approaches consume the same URL; the difference is that the Router approach integrates better with browser history (on web targets) and declarative navigation state. If you're starting a new project, going straight with Router as the official guide recommends saves you a future migration cost.

Instead of manually checking the two server-side files (AASA and assetlinks.json) line by line, the fastest way to validate the setup before writing any code is the DevTools Deep Links tab. Per the official description, it imports a Flutter project and detects errors in the deep-link setup — from the website configuration to the manifest files — offering instructions to fix them. Since 3.27, the validator works for both Android and iOS — as of this article's writing, 2025-12-17 (then-current stable: 3.38.5), it had been available for about a year, so you can check both platforms from a single tool.

In practice the flow is: DevTools → Deep Links → select the project root → the tool fetches the AASA/assetlinks.json files live from the server and also reads the Xcode/Gradle configuration, listing out mismatched fields (wrong Team ID, missing intent-filter, wrong package name) one by one.

The cold start vs. warm start difference

The moment a deep link reaches the app, it passes through a different API depending on the app's current state at that moment. This difference isn't symmetric across platforms either:

State
Android (while closed)
iOS (while closed)
Both (while open)
First signal
initialRoute carries the target path directly (e.g. /detail)
initialRoute arrives as / first
—
Second signal
none (correct path in one shot)
shortly after, a separate pushRoute call delivers the actual link
pushRoute is called
Risk
low
the initial screen may briefly show the wrong content
low

The practical consequence: your route-capturing code must listen to both initialRoute and pushRoute (or RouteInformationParser with Router). Relying only on initialRoute looks fine on Android but misses the link on iOS in a cold-start scenario — a common source of "it sometimes doesn't work on iOS" complaints.

Common mistakes and how to diagnose them

The table below lists the most common setup mistakes based on the official rules covered in the sections above, along with the direct way to diagnose each one:

Symptom
Root cause
Diagnostic command/path
Link opens in Safari on iOS, app doesn't open
AASA is behind a 301/302 redirect
Check the raw response with swcutil dl -d <domain>
Disambiguation dialog still shows up on Android
assetlinks.json is served with the wrong content-type
Verify Content-Type: application/json with curl -I
Verification stays stuck "pending"
Tested before the 20-second async window elapsed
Query again with adb shell pm get-app-links
assetlinks.json is correct but verification fails
sha256_cert_fingerprints was taken from the debug keystore
Use the real fingerprint from Play Console → App signing
Wrong screen briefly shows on iOS cold start
Code only listens to initialRoute, doesn't capture pushRoute
Listen to both APIs from the cold/warm start table

Relationship with analytics and attribution

Query parameters in the deep link URL (campaign source, referral code, etc.) must be passed to the analytics event AFTER route mapping — Router/onGenerateRoute parses the URL first, then the app passes those parameters to its analytics call; this guarantees no analytics event fires unless the parse step already produced a valid result.

The practical rule: don't send any analytics event until the route has been parsed. Otherwise you'd log a "successful open" event even for an invalid or incomplete deep link, polluting your funnel/attribution data. The parse result inside Router/onGenerateRoute (success or not, which path) should be the single source of truth.

Test checklist

Before calling the setup "done," go through these in order:

  • iOS AASA access: download the file with swcutil dl -d <domain>, confirm there's no 301/302.
  • iOS entitlement match: verify the target path with swcutil verify -d <domain> -j aasa.json -u <test-url>.
  • Android assetlinks.json: check Content-Type: application/json and no redirect with curl -I https://<domain>/.well-known/assetlinks.json.
  • Android verification status: run adb shell pm get-app-links <package> at least 20 seconds after install, expect verified.
  • DevTools Deep Links: open the project root and confirm "no issues found" for both Android and iOS.
  • Cold start scenario: fully close the app, tap the link; observe that the correct screen opens on both Android and iOS (even with a brief delay on iOS).
  • Warm start scenario: tap the link while the app is open, confirm pushRoute/RouteInformationParser fires.

GOLDEN TIP

The most valuable insight in this article

This tip holds the article's most important takeaway.

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?

Reader Reward

I've gathered all the commands and file paths from this guide into a single checklist, so you can look at one place instead of jumping between tabs while doing the setup. The list covers both iOS and Android, both the file paths and the verification commands, plus the most commonly skipped waiting period — for anyone who wants to see, on one screen, the practical info scattered across all the sections above.

FAQ

You need to set up three parts separately: on iOS, the Associated Domains entitlement + /.well-known/apple-app-site-association file; on Android, the android:autoVerify="true" intent-filter + /.well-known/assetlinks.json file; and on the Flutter side, code that reflects the URL onto the screen using Router/RouteInformationParser (or named routes). Once the setup is done, you can check both platforms at once with DevTools' Deep Links validator.

The three most common reasons: the AASA file is behind a 301/302 redirect (which isn't supported per Apple's rule), the applinks:<domain> format in the entitlement doesn't match the domain, or the CDN cache hasn't updated yet (you can add ?mode=developer (alternate mode) to bypass Apple's CDN and test the file directly). The swcutil dl and swcutil verify commands let you tell these three apart.

Where does the apple-app-site-association file go?

It goes at the path /.well-known/apple-app-site-association in the server's root, reachable over HTTPS with no redirect. If applinks defines multiple subdomains, the file must be hosted separately on each subdomain — keeping a single file on the main domain and redirecting from the subdomains isn't supported.

For the server side, confirm the files are served correctly with swcutil (iOS) and curl -I + adb shell pm get-app-links (Android) commands. For the app side, run the DevTools Deep Links validator, then manually test both the cold start scenario (tapping the link while the app is fully closed) and the warm start scenario (while the app is open) — since these two scenarios go through different Flutter APIs, they must be verified separately.

Why does Android verification take 20 seconds?

When Android sees the autoVerify="true" flag, it queries the Digital Asset Links file for the relevant hosts asynchronously in the background; this process isn't synchronous, and the official guide says to wait at least 20 seconds. If you check with adb shell pm get-app-links before this time elapses, you may not yet see the status as verified.

Update (September 2026)

This article was written on 2025-12-17 with Flutter 3.38.5. Since then, there's been no breaking change to the core mechanics of the setup (AASA schema, assetlinks.json fields, autoVerify flow); one point is still worth noting:

  • Flutter stable 3.38.6 was released: This article was written with 3.38.5 stable; per Flutter's official release archive, the stable channel advanced to 3.38.6 on 2026-01-08. This release brought no changes to the deep link setup steps above (AASA schema, assetlinks.json fields, autoVerify flow, Router/RouteInformationParser usage); it's noted here only as a patch release.

Conclusion

The trick to deep link setup isn't the code, it's serving the two verification files on the server (AASA and assetlinks.json) completely and without redirects. On the Flutter side, once you listen to both initialRoute and pushRoute via Router/RouteInformationParser, the cold/warm start difference is neutralized too. Once the setup is done, checking both platforms at once with DevTools' validator is the cheapest verification step before shipping to prod.

If you want to go deeper into Flutter's navigation and state management side, check out Flutter State Management: Riverpod Guide; for the native bridge side, Flutter iOS Platform Channel, and for the architecture side, Flutter Clean Architecture round out this guide. If you want to expand your test checklist, see Flutter Testing: Complete Guide, and for the latest Dart language features, Dart 3: New Features. Custom Navigation in SwiftUI, which tackles a similar navigation/deep-linking problem from a different angle (the Coordinator pattern) in SwiftUI, also offers a related perspective on this topic.

Sources

Tags

#flutter#deep-link#universal-links#app-links#ios#android#routing
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share