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
- Three separate things: custom scheme, App Links, Universal Links
- iOS: entitlement + apple-app-site-association + server rules
- Android: assetlinks.json + intent filter + signing fingerprint
- Route mapping in Flutter and capturing the initial link
- The DevTools deep link validator
- The cold start vs. warm start difference
- Common mistakes and how to diagnose them
- Relationship with analytics and attribution
- Test checklist
- FAQ
- How do you set up deep links in Flutter?
- Why aren't Universal Links working?
- Where does the apple-app-site-association file go?
- How do you test deep links in Flutter?
- Why does Android verification take 20 seconds?
- Update (September 2026)
- Conclusion
- Sources
Three separate things: custom scheme, App Links, Universal Links
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.
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."
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:
1# AASA download test (as Apple sees it)2sudo swcutil dl -d example.com3 4# Test whether a specific path matches the entitlement5sudo swcutil verify -d example.com -j /path/to/aasa.json -u https://example.com/detail/42To 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"].
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:
1keytool -list -v -keystore my-release-key.keystoreWith 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:
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:
1adb shell pm get-app-links com.example.appSuccessful domains show as verified; states like none, legacy_failure, or 1024+ indicate a problem (source: developer.android.com/training/app-links/verify-android-applinks).
Route mapping in Flutter and capturing the initial link
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.
1class AppRouteInformationParser extends RouteInformationParser<AppRoutePath> {2 @override3 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:
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.
The DevTools deep link validator
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/jsonand no redirect withcurl -I https://<domain>/.well-known/assetlinks.json. - Android verification status: run
adb shell pm get-app-links <package>at least 20 seconds after install, expectverified. - 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/RouteInformationParserfires.
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
How do you set up deep links in Flutter?
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.
Why aren't Universal Links working?
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.
How do you test deep links in Flutter?
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/RouteInformationParserusage); 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
- Android App Links — overview — the official page explaining how App Links bypasses the disambiguation dialog and its Android 6.0+ support.
- Android — assetlinks.json configuration — the official schema for the
package_name,sha256_cert_fingerprints, andrelationfields. - Android — App Links verification —
autoVerify, the 20-second async verification, and theadb shell pm get-app-linkscommand. - Apple TN3155 — Debugging Universal Links — entitlement format, the AASA redirect restriction,
swcutilcommands. - Flutter — Deep linking guide — named routes/
Routermapping and the cold/warm start behavior table. - Flutter — App Links setup guide (cookbook) — the official source showing that the
flutter_deeplinking_enabledmeta-data had to be added manually on pre-3.27 versions. - Flutter DevTools — Deep Links validator — the tool's scope and its two-platform support since 3.27.
- Flutter release info (releases feed) — stable channel version/date data (source for the Update section).
Tags
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.

