Flutter's go_router or uni_links-based setup gets you clean routing once a link actually reaches the app. That's the easy 90%. The part that quietly doesn't work by default on either platform is a new install: user taps a link, has no app, goes through the store, installs, opens — and Flutter boots to your normal initial route with zero memory of the link. This is the complete guide to wiring up both the direct and deferred cases in a Flutter app with LinkTrail, covering the Dart side and the native config both platforms still require underneath it.
The two cases
- Direct link — app is already installed. Both iOS and Android hand the link to the native layer (Universal Link / App Link), and it needs to be routed into Flutter through a platform channel.
- Deferred link — app is not installed. Neither OS delivers anything on first launch — there's no event to catch, on either platform. Resolving it means matching against a server-side record from a signal captured at tap-time, which is a different mechanism from link routing entirely, not a Flutter-side gap.
Flutter doesn't remove the native setup — it just needs to happen once, in each platform project, same as a fully native app.
1. Add the package
# pubspec.yaml
dependencies:
linktrail_flutter: ^1.1.0
flutter pub get
2. Native configuration (still required)
Android — android/app/src/main/AndroidManifest.xml
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<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="links.yourapp.com" />
</intent-filter>
</activity>
Serve assetlinks.json at https://links.yourapp.com/.well-known/assetlinks.json with your release-key SHA-256 fingerprint — a debug-keystore fingerprint here is the single most common reason this works in dev and silently fails in production.
iOS — Associated Domains
In Xcode, add the Associated Domains capability to the Runner target:
applinks:links.yourapp.com
Serve apple-app-site-association at https://links.yourapp.com/.well-known/apple-app-site-association, Content-Type: application/json, no redirects:
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID.com.yourcompany.yourapp"],
"components": [{ "/": "/*" }]
}
]
}
}
Both files need to exist and be correct before anything on the Dart side matters — if they're wrong, the OS never hands your app anything to resolve in the first place.
3. Initialize the SDK
import 'package:linktrail_flutter/linktrail_flutter.dart';
import 'package:flutter/material.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await LinkTrail.configure(apiKey: 'YOUR_LINKTRAIL_API_KEY');
runApp(const MyApp());
}
4. Handle the direct case
class _MyAppState extends State<MyApp> {
StreamSubscription<LinkTrailLink>? _linkSub;
@override void initState() {
super.initState();
// Fires for a link tapped while the app is already installed —
// whether it was running in the background or launched cold by the tap.
_linkSub = LinkTrail.linkStream.listen((link) {
_routeFromLink(link);
});
}
@override void dispose() {
_linkSub?.cancel();
super.dispose();
}
}
5. Handle the deferred case
@overridevoid initState() {
super.initState();
_linkSub = LinkTrail.linkStream.listen(_routeFromLink);
// Resolves once, only on the first launch after install.
// Safe to always call — it completes with null on every later launch.
LinkTrail.getFirstOpenLink().then((link) {
if (link != null) _routeFromLink(link);
});
}
linkStream is your ongoing listener for the app's lifetime; getFirstOpenLink is a one-shot future you check once at startup. Mixing them up — say, only checking getFirstOpenLink and never subscribing to the stream — means links stop working the moment a user has the app installed, which is a bug that's easy to miss in testing if you only ever test on a fresh install.
6. Route on the resolved link
void _routeFromLink(LinkTrailLink link) {
switch (link.path) {
case '/product':
final id = link.params['id'];
context.go('/product/$id');
break;
case '/invite':
final ref = link.params['ref'];
context.go('/onboarding?referrer=$ref');
break;
default:
context.go('/home');
}
}
(Swap context.go for your router of choice — Navigator, auto_route, whatever the rest of the app already uses. The resolution logic above doesn't care.)
7. Test both platforms, both cases
Deferred linking is resolved natively under the hood even in a Flutter app, so test per-platform, not just once:
-
Android direct: app installed, tap a link, confirm
linkStreamfires. Then background the app and tap again — confirm it still fires, not just on cold start. -
Android deferred: uninstall completely, tap the link, install via Play Store, open. Confirm
getFirstOpenLinkresolves. - iOS direct: send yourself the link via Messages (not Safari's address bar — that doesn't trigger a Universal Link). Tap with the app installed.
- iOS deferred: delete the app, tap the link, install via App Store, open cold.
-
Check native verification independently of Flutter —
adb shell pm get-app-links your.package.nameon Android, and a direct fetch of the AASA file on iOS. If either fails at the native layer, no amount of Dart-side debugging will fix it, because the link never reaches Flutter to begin with.
What's next
This gets link data flowing into your Flutter app in both states. Attribution — which campaign or creative actually drove the install — sits on top of this same resolution and is worth covering separately.
Top comments (2)
Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support
Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more