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 (1)
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