Flutter: Determining Deep Links & Universal Links Issues
1. "Links Open in Safari, Not My App"
You sent a promo SMS: https://myapp.com/event/123.
User clicks it. They have the app installed.
Expectation: The app opens directly to the event page.
Reality: Safari opens the web version (or a 404 page).
User thinks: "This app adds friction," and leaves. Deep linking is the bridge between the web and your app. If the bridge is broken, traffic falls into the river.
2. The Principle: Web Credentials
Gone are the days of insecure Custom Schemes (myapp://). We now use Universal Links (iOS) and App Links (Android).
The core concept is Domain Verification.
Your website must host a specific file that says: "Yes, I own the app with Bundle ID com.example.app. It is safe to open me." without user confirmation.
3. Solution 1: Server Configuration (Crucial)
If you don't host these files correctly, nothing you do in the app code will matter.
Android (assetlinks.json)
Location: https://yourdomain.com/.well-known/assetlinks.json
Content: A JSON mapping your package name to your SHA-256 Fingerprint.
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": ["AE:89:your:debug:and:prod:sha256:keys"]
}
}]
Note: Make sure to verify the SHA-256 for BOTH Debug and Release keystores.
iOS (apple-app-site-association)
Location: https://yourdomain.com/.well-known/apple-app-site-association
Content: JSON (without .json extension in URL).
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.example.myapp",
"paths": [ "/event/*", "/user/*" ]
}
]
}
}
Server Requirement: The file MUST be served with Content-Type: application/json and must be accessible via HTTPS without redirects.
4. Solution 2: App Configuration
Now inform the OS that the app claims the domain.
Android (AndroidManifest.xml)
The autoVerify="true" attribute is the magic switch. It bypasses the "Open with..." dialog.
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="myapp.com" />
</intent-filter>
iOS (Xcode)
- Target ->
Signing & Capabilities. + Capability->Associated Domains.- Add entry:
applinks:myapp.com(No https, no paths).
5. Solution 3: Routing Logic (go_router)
Flutter receives the URL string. You need to parse it and navigate.
go_router handles standard URL path matching out of the box.
final goRouter = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => HomePage(),
routes: [
GoRoute(
path: 'event/:id',
builder: (context, state) {
// Extracts '123' from '/event/123'
final id = state.pathParameters['id'];
return EventPage(id: id);
},
),
],
),
],
);
No manual regex parsing required. It just works.
6. Deep Dive: Deferred Deep Links
What if the user doesn't have the app?
- User clicks link.
- Redirected to App Store.
- User installs and opens app.
- Goal: App immediately navigates to
event/123.
Standard Deep Links can't do this (context is lost during install). You need Deferred Deep Linking. Since Firebase Dynamic Links is sunsetting, you have two options:
- Paid SDKs: AppsFlyer, Branch, Adjust. They rely on "Fingerprinting" (Device model + IP + OS version) to probabilistically match the user.
- DIY: When user opens app, query your own server with current IP/Device info to check for recent link clicks. (Hard to get right).
7. Deep Dive: GoRouter ShellRoute
If your deep link opens a page, but the user feels "trapped" because the Back button closes the app or the Bottom Tab Bar is missing, you have a Navigation UX issue.
Use ShellRoute to wrap your deep-linked screens inside the main app scaffold.
ShellRoute(
builder: (context, state, child) {
// Keeps the BottomNavigationBar visible even on deep linked pages
return Scaffold(
body: child,
bottomNavigationBar: MainBottomNav(),
);
},
routes: [
GoRoute(path: '/event/:id', ...),
],
)
This ensures a consistent app experience, regardless of entry point.
8. Deep Dive: Flavor & Environment Management
Real-world apps have multiple environments: Dev, Staging, Prod. Your deep link domains should reflect this to prevent testers from opening the production app.
- Dev:
dev.myapp.com - Prod:
myapp.com
iOS Configuration (Build Settings):
Use a User-Defined Setting like DEEP_LINK_DOMAIN in Build Settings.
Set it to dev.myapp.com in Debug config, and myapp.com in Release config.
In Associated Domains, use applinks:$(DEEP_LINK_DOMAIN).
Android Configuration (Manifest Placeholders):
Use manifestPlaceholders in build.gradle.
productFlavors {
dev {
manifestPlaceholders = [hostName: "dev.myapp.com"]
}
prod {
manifestPlaceholders = [hostName: "myapp.com"]
}
}
In AndroidManifest.xml, use ${hostName}.
This strictly separates your environments.
9. Pro Tip: Safe URL Parsing
Never use Uri.parse(urlString) directly on user input or deep links. It throws an exception if the URL is malformed, causing your app to crash.
Instead, use Uri.tryParse(urlString).
final uri = Uri.tryParse(incomingDeepLink);
if (uri == null) {
print("Failed to parse deep link");
return;
}
// Proceed safely
Also, always check uri.scheme and uri.host before acting on it. Someone could send a malicious deep link like myapp://delete_account. Validating the authority is crucial.
10. Deep Dive Glossary
- Custom Scheme:
myapp://. Old way. Insecure. User gets a scary prompt. Avoid. - Universal Link:
https://myapp.com. iOS standard. Secure because it requires server-side AASA file. - App Link: Android equivalent of Universal Link. Requires
assetlinks.json. - AASA (Apple App Site Association): The JSON file on your server proving ownership to Apple.
- Deferred Deep Link: Deep linking that works after a fresh install.
- Manifest Placeholder: A variable injection mechanism in Android build system.
11. Troubleshooting: Verifying Your Setup
If your deep links are still failing, follow this checklist. 99% of issues are one of these three:
- JSON Syntax Error: The
assetlinks.jsonor Apple's AASA file is invalid JSON.- Fix: Paste your file content into
jsonlint.com. A single missing comma breaks everything.
- Fix: Paste your file content into
- Content-Type Header: The server must return correct headers.
- Fix: Run
curl -I https://yourdomain.com/.well-known/apple-app-site-association. Ensure it saysContent-Type: application/json. If it saystext/plainorapplication/octet-stream, iOS will ignore it.
- Fix: Run
- Redirects: The file must be available at the exact URL.
- Fix: Ensure no
301 Redirectsare happening. It must hold a200 OKstatus.
- Fix: Ensure no
12. Conclusion
Deep links are the entry door to your app. If the door is jammed, users won't force it open—they'll just walk away to a competitor's website.
Setting up Universal Links and App Links feels like "Black Magic" because it involves three parties:
- The OS (iOS/Android) verifying the domain.
- The Server hosting the proof files.
- The App handling the incoming intent.
If any one of these is misconfigured, it fails silently. By following the steps—Server Validation, Manifest/Project Setup, and GoRouter parsing—you can build a robust navigation system that feels native and magical to the user.
Don't leave the door locked. Welcome your users properly.