A link to a product page should open the product page, not a browser tab asking the user to install an app they already have. That gap is where deep linking earns its keep: a shared link, a marketing email, a QR code, or a push notification lands the user on exactly the right screen, logged in, with the back stack intact.
Flutter makes the in-app half easy. The part that trips developers up is verification: proving to Android and iOS that your app is allowed to handle your domain. This guide covers the whole chain — Android App Links, iOS Universal Links, custom schemes as a fallback, and the Dart code that turns an incoming URL into a route — with every file you need.
The three kinds of links
| Type | How it opens | When to use it |
|---|---|---|
| Custom scheme (myapp://products/42) | Always shows a chooser if several apps claim it | Internal links, development, fallback |
| Android App Link (https://example.com/products/42) | Opens your app directly, no chooser | Production links on Android |
| iOS Universal Link (https://example.com/products/42) | Opens your app directly, no chooser | Production links on iOS |
The rule of thumb: ship verified HTTPS links for anything a user can tap, and keep a custom scheme only as a fallback for environments where you do not control a domain. Verified links also survive the case where the app is not installed — they simply open the website — while a custom scheme shows an error.
Android: App Links
Android verification has two halves: the app declares the domains it wants, and the domain publishes a file confirming the app.
1. Declare the intent filter
In android/app/src/main/AndroidManifest.xml, add an intent filter to the main activity. The android:autoVerify="true" attribute is the key detail: it tells Android to check the domain at install time, and on Android 12 and later a link is only treated as verified when this check passes.
<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="example.com" />
</intent-filter>
Use one intent filter per host, and keep the path open: verification applies to the whole host, and you decide which paths the app handles in Dart.
2. Publish assetlinks.json
Serve this file at https://example.com/.well-known/assetlinks.json over HTTPS, with content type application/json, no redirects, and no authentication gate:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.shop",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
]
}
}
]
The fingerprint must match the key that signed the build. List both fingerprints if you test debug and release builds against the same domain — a debug build signed with the debug key will not verify against a file that only lists the release fingerprint. Get a fingerprint with:
keytool -list -v -keystore android/app/release.keystore -alias upload
For the debug key, the keystore is ~/.android/debug.keystore with alias androiddebugkey and password android.
3. Confirm verification
Android re-checks verification at install time, so after changing the file, uninstall and reinstall the app rather than just relaunching it. You can also check the system state with:
adb shell pm get-app-links com.example.shop
If the domain shows as unverified, check the file path first, then the fingerprint, then the content type — those three cover nearly every failure.
iOS: Universal Links
iOS verification mirrors Android: a capability in the app, and a file on the domain.
1. Add the Associated Domains capability
In Xcode, open the Runner target, go to Signing and Capabilities, add Associated Domains, and add one entry per domain:
applinks:example.com
Do not add a scheme here, and do not add a path: the domain entry covers the whole host, and path filtering happens in the association file.
2. Publish apple-app-site-association
Serve this file at https://example.com/.well-known/apple-app-site-association over HTTPS, with content type application/json, and no .json extension on the URL:
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID.com.example.shop"],
"components": [
{ "#": "no_universal_links", "comment": "Matches a page that should stay in the browser" },
{ "/": "/products/*" },
{ "/": "/orders/*" }
]
}
]
}
}
The app ID is your Team ID followed by the bundle ID, and you can find the Team ID in your Apple Developer account under Membership. The components list is evaluated in order, so put exclusions before the patterns they override. A path that matches no component falls back to the browser, which is exactly what you want for pages like login or legal content.
3. Confirm on device
iOS fetches the association file when the app is installed, and it caches aggressively. As on Android, uninstall and reinstall after changing the file. Test on a real device or simulator with:
xcrun simctl openurl booted https://example.com/products/42
If the link opens Safari instead of the app, check the file URL first (the missing-extension detail catches people), then the app ID, then whether the device actually reinstalled the app after the file changed.
Custom schemes as a fallback
A custom scheme needs no domain and no verification file, which makes it useful for development and for links that never leave your own app. Declare it in the manifest alongside the HTTPS filter, and in ios/Runner/Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myshop</string>
</array>
</dict>
</array>
Treat custom schemes as untrusted input: any app on the device can claim the same scheme, so never put tokens or sensitive data in a custom-scheme link, and never let one trigger a privileged action without a logged-in session check.
Handling links in Dart
The app_links package gives you both halves of the problem: the link that launched the app, and the stream of links that arrive while it runs. The stream alone is not enough — it only fires when the app is already alive, so a cold start would silently drop the link.
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
final _router = GoRouter(
routes: [
GoRoute(path: '/', builder: (_, _) => const HomeScreen()),
GoRoute(
path: '/products/:id',
builder: (_, state) => ProductScreen(id: state.pathParameters['id']!),
),
GoRoute(
path: '/orders/:id',
builder: (_, state) => OrderScreen(id: state.pathParameters['id']!),
),
],
);
Future<void> initDeepLinks() async {
final appLinks = AppLinks();
// Cold start: the link that launched the app, if any.
final initialLink = await appLinks.getInitialLink();
if (initialLink != null) {
_handleLink(initialLink);
}
// Warm state: links that arrive while the app is running.
appLinks.uriLinkStream.listen(_handleLink);
}
void _handleLink(Uri uri) {
// Route from the path only, so the destination survives a cold start.
_router.go(uri.path);
}
Two details matter here. First, route from uri.path, not from extra state or a cached object, because a cold-started app has no previous session to read from. Second, guard routes that need login the same way you guard normal navigation — for example with the redirect pattern covered in Flutter go_router: The Navigation Guide — so a shared link to a private screen lands on login first and then continues to the destination.
Links from push notifications follow the same path: when a notification tap carries a URL, feed it through the same handler instead of building a second routing system. The notification setup itself is covered in Flutter Firebase Push Notifications Guide.
Testing checklist
- Android, cold start: kill the app, then run
adb shell am start -a android.intent.action.VIEW -d "https://example.com/products/42"and confirm the product screen opens, not the browser. - Android, warm state: with the app open on another screen, run the same command and confirm it navigates in place.
- iOS: paste the link into Notes and tap it, or use
xcrun simctl openurl, and confirm the app opens without a Safari intermediate page. - App not installed: tap the link on a clean device and confirm the website opens, since the HTTPS link degrades gracefully.
- After any change to either verification file: uninstall and reinstall the app before retesting, because both platforms cache verification at install time.
- Edge cases: a link to an excluded path should open the browser, a link with an unknown product ID should show a not-found screen inside the app rather than crashing, and a link tapped while logged out should route through login first.
Troubleshooting
| Symptom | Most likely cause | Fix |
|---|---|---|
| Android opens the browser | Verification failed | Check the assetlinks.json path, HTTPS, content type, and SHA-256 fingerprint, then reinstall |
| Android shows an app chooser | autoVerify missing or failed | Add android:autoVerify="true" and confirm verification with adb |
| iOS opens Safari | Association file problem | Check the well-known URL without a .json extension, the app ID, and reinstall |
| iOS opens the app for excluded paths | Component order | Put exclusions before the wildcard patterns they override |
| Link works warm but not cold | Only the stream is handled | Add getInitialLink handling at startup |
| Link opens the wrong screen | Routing from extra state | Route from uri.path so cold starts resolve the same way |
Pre-launch checklist
Before you ship deep links, confirm the whole chain once: intent filter with autoVerify, assetlinks.json live over HTTPS with both fingerprints, Associated Domains capability, apple-app-site-association live without a file extension, cold-start and warm handling in Dart, login guards on private routes, and a graceful website fallback when the app is absent. Deep links are infrastructure, not a feature — when they work, nobody notices them, and that silence is the point.
Get in touch and let's talk about your project.
Originally published at uzairxdev.online
Top comments (0)