Flutter Deep Linking: Universal Links and App Links Setup Guide
Flutter deep linking takes four pieces: Universal Links on iOS, App Links on Android, Flutter's own link flag, and a safe cold start. Set up each end to end.
TL;DR: Flutter deep linking has four layers, and a link opens your app only when all four agree. On iOS you add an Associated Domains entry for your link host. On Android you add an intent filter with
android:autoVerify="true"and setminSdkto 26. Both platforms read a hosted association file that must match your signing identity. Flutter also ships its own deep link handler, enabled by default, and it forwards the same URL to your router as a route, so you switch it off and let one source drive navigation. In Dart,WarpLink.configurecalled from the root widget'sinitStatewires cold start, warm start, and the first-launch deferred check into oneonLinkcallback, with noAppDelegate,SceneDelegate, orMainActivityedits. The bug almost everyone hits is the cold start race: the link arrives before the first frame, so you wait for the navigator. The rest of this guide is each layer in full, the Dart that routes it, and the commands to test it.
Flutter Deep Linking: The Three Kinds of Links
Flutter deep linking is the wiring that lets a URL open a specific screen inside your app instead of a web page, on both iOS and Android.
Before any code, get the vocabulary right, because the three link types have different failure modes and the fixes do not transfer.
| Link type | Looks like | Verified by the OS | Works without the app |
|---|---|---|---|
| Custom URL scheme | myapp://product/42 | No | No. The tap dead-ends |
| Universal Link (iOS) | https://yourapp.aplnk.to/abc123 | Yes, through apple-app-site-association | Yes. Opens the web page |
| App Link (Android) | https://yourapp.aplnk.to/abc123 | Yes, through assetlinks.json | Yes. Opens the web page |
A custom scheme is a claim, not a proof. Any app on the device can declare myapp://, and on Android the last one installed can win the chooser. It needs no server, which makes it fine for local development and unusable for anything you publish, because a user without your app gets a browser error page rather than your site.
A Universal Link and an App Link are the same idea on two platforms: an ordinary https URL that the operating system has verified belongs to you, by fetching a file from your domain and comparing it to the app's signing identity. No other app can intercept it. If the app is missing, the URL is still a URL, so the browser loads your web page and you can send the user to the store from there. This is what you ship.
Flutter does not change any of that. Verification happens entirely in the native layer, before a line of Dart runs. What Flutter adds is a second consumer of the same URL: its own deep link handler, which turns the incoming URL into a route for your router. That is useful when your router owns every link, and a source of duplicate navigation when a link resolver such as WarpLink also delivers the link. So the work splits into two native setups, one decision about Flutter's handler, and one Dart setup that is identical across both platforms.
Throughout this guide the example link host is yourapp.aplnk.to, the example bundle identifier and package name are com.example.myapp, and the example link is https://yourapp.aplnk.to/abc123. Substitute your own. To find your app's link host, open the app in the dashboard, then Settings.
Step 1: Install the Plugin
flutter pub add warplink_flutter
The package is warplink_flutter 1.1.0 on pub.dev, MIT licensed, with zero third-party runtime dependencies. It needs Flutter 3.38 or newer, Dart 3.10 or newer, iOS 15 or newer, and Android API 26 or newer. It runs on iOS and Android only. On web and desktop every call throws UnsupportedError, so guard the call if your app also builds for those targets.
The iOS side is delivered through Swift Package Manager. Flutter 3.44 and later enable it by default. On Flutter 3.38 to 3.43, run this once on the machine that builds the app:
flutter config --enable-swift-package-manager
There is no Podfile change and no pod install step. Android links automatically through Gradle.
Step 2: Set Up Universal Links on iOS
Two pieces, both configuration: the entitlement and the hosted file. There is no delegate code.
The Associated Domains entitlement
In Xcode, open ios/Runner.xcworkspace, select the Runner target, open Signing & Capabilities, add Associated Domains, and add one entry per link host in the form applinks:yourapp.aplnk.to. If your app existed before app subdomains and its links live on aplnk.to, also keep applinks:aplnk.to listed. That writes into ios/Runner/Runner.entitlements:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:yourapp.aplnk.to</string>
</array>
Enable the same capability on your App ID in the Apple Developer portal and regenerate the provisioning profile if needed, or signing will fail with a mismatched entitlement.
During development, append ?mode=developer to the entry (applinks:yourapp.aplnk.to?mode=developer) and turn on Settings > Developer > Associated Domains Development on the device. That makes iOS fetch the association file straight from your server instead of through Apple's cache. Remove the suffix before you ship.
The apple-app-site-association file
Apple verifies the entitlement against a file served from the same host at https://yourapp.aplnk.to/.well-known/apple-app-site-association:
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.myapp"],
"components": [
{ "/": "/*", "comment": "Every short link on this host" }
]
}
]
}
}
ABCDE12345 is your Apple Team ID, from the Membership page of the developer portal. The rules that trip people up, in the order they usually bite:
- Serve it over HTTPS with a valid certificate, with no redirect at any hop. A 301 to
www.is a failure, not a detour. Content-Type: application/json. No.jsonextension on the path.- No authentication, no cookie wall, no bot challenge in front of it. Apple's fetcher is not a browser.
- Order matters inside
components. The first matching entry wins, so putexcluderules above the broad patterns they carve out of. - Keep it small. Apple caps the file at 128 KB.
The file is fetched by Apple's content delivery service at install time and refreshed periodically, not on demand. That cache is the single most common reason a fix "does not work": the file on your server is correct and the copy on the device is not. Developer mode above bypasses it, and so does a delete and reinstall of the app.
No AppDelegate or SceneDelegate edits
The plugin registers with Flutter's application and scene delegate chain, so incoming Universal Links and custom-scheme URLs reach the SDK on their own. That holds for apps on the classic AppDelegate life cycle and for apps that have adopted the UIScene life cycle, and for both cold start and warm start.
One rule applies if your app already overrides link methods. If your own AppDelegate or SceneDelegate implements application(_:continue:restorationHandler:), application(_:open:options:), scene(_:continue:), or scene(_:openURLContexts:), call super from it. Flutter's chain delivers the URL to plugins from super, so an override that returns without it stops the chain:
override func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
// ...your own handling...
return super.application(
application, continue: userActivity, restorationHandler: restorationHandler)
}
If you combine the results of several handlers, store each result in a local first. A || chain short-circuits, so a handler placed after one that returns true never runs, and that is a very quiet way to lose another plugin's callback.
Step 3: Set Up App Links on Android
Three pieces on the device, one on the server.
minSdk
The WarpLink Android SDK needs API 26, and Flutter's default minSdk can be lower, so the manifest merge fails until you raise it. In android/app/build.gradle.kts:
android {
defaultConfig {
minSdk = 26
}
}
If your project still uses the Groovy file android/app/build.gradle, write minSdkVersion 26 instead.
The manifest
Add the intent filter inside your main <activity> in android/app/src/main/AndroidManifest.xml, not directly under <application>:
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- App Links: verified https, opens the app with no chooser -->
<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="yourapp.aplnk.to" />
</intent-filter>
</activity>
If your app existed before app subdomains and its links live on aplnk.to, keep a second <data android:scheme="https" android:host="aplnk.to" /> in the filter, next to the new host. Links you already have keep opening your app.
Two details do more work than they look like they do.
android:launchMode decides whether warm start works. A link tapped while the app runs must reach the running activity, which needs singleTop or singleTask. The flutter create template already writes singleTop on MainActivity, so keep it. With standard, Android starts another activity instead of delivering the intent to the running one, and the plugin never sees the warm-start URL. singleInstance is not supported.
Keep any custom scheme in a separate intent filter. An autoVerify filter is verified as a unit, and Android's verifier only knows how to verify http and https data elements. Put myapp in the same block and verification for the whole filter fails, which takes your https App Links down with it.
The assetlinks.json file
Android verifies the intent filter against https://yourapp.aplnk.to/.well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": [
"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"
]
}
}
]
Same serving rules as the iOS file: HTTPS, no redirects, application/json, publicly reachable. Android's verifier will not follow a redirect.
The fingerprint is where App Links go wrong most often. It must be the SHA-256 of the certificate that signs the APK the user actually installs. Once you publish through Google Play, Google re-signs your app with the Play App Signing key, so the fingerprint of your local upload keystore is not the one on the device. The field accepts a list, so one file can cover every build you test:
- The app signing key from the Play Console, for store and internal-testing builds.
- The upload key from the same page, for builds you sideload before uploading.
- Your local debug key, which is what
flutter runsigns with:
keytool -list -v -keystore ~/.android/debug.keystore \
-alias androiddebugkey -storepass android -keypass android
Verification behaviour by Android version
Verification runs at install time and needs network access. On Android 12 and newer (API 31 and up), an unverified https link no longer prompts with a chooser. It just opens in the browser, silently. That silence is why so many teams believe App Links "stopped working" on newer devices when verification failed and nothing said so.
Step 4: Switch Off Flutter's Own Deep-Linking Flag
This is the step that is specific to Flutter, and the one most guides skip.
Flutter has its own deep link handler, enabled by default. When it is on, Flutter also forwards https://yourapp.aplnk.to/abc123 to your router as a route. On an Android cold start that route can reach the router without its host, so the router cannot tell a WarpLink URL from any other path. Apps on go_router or Navigator 2 then show an "unknown route" page next to the WarpLink delivery.
Switch Flutter's handler off and let onLink drive navigation. On iOS, in ios/Runner/Info.plist:
<key>FlutterDeepLinkingEnabled</key>
<false/>
On Android, inside the <activity> in AndroidManifest.xml:
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
If your app must keep Flutter's own deep linking for other, non-WarpLink domains, WarpLink cannot share the same links safely. Keep the WarpLink hosts out of Flutter's route table, and do not rely on router-side host matching to recognize WarpLink URLs.
Step 5: Configure From the Root Widget
Call WarpLink.configure once, from the initState of your root widget, and pass an onLink callback. That one sink receives cold-start links, warm-start links, and deferred links from a first launch after install.
Start with the navigation helper. It owns the navigatorKey, turns a link into a route, and waits for the navigator when the link beats the first frame:
// lib/link_navigation.dart
import 'package:flutter/widgets.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
final navigatorKey = GlobalKey<NavigatorState>();
typedef LinkRoute = ({String name, Object? arguments});
/// Picks a route from the link's in-app URL, for example myapp://product/42.
/// A link with no in-app URL, or one this app does not recognize, opens its
/// web destination instead.
LinkRoute routeFor(WarpLinkDeepLink link) {
final inAppUrl = link.deepLinkUrl;
if (inAppUrl != null) {
final uri = Uri.parse(inAppUrl);
final segments = [
if (uri.host.isNotEmpty) uri.host,
...uri.pathSegments.where((segment) => segment.isNotEmpty),
];
switch (segments) {
case ['product', final id]:
return (name: '/product', arguments: id);
}
}
return (name: '/web', arguments: link.destination);
}
void openLink(WarpLinkDeepLink link) {
final route = routeFor(link);
void push() => navigatorKey.currentState
?.pushNamed(route.name, arguments: route.arguments);
if (navigatorKey.currentState != null) {
push();
return;
}
// The first frame has not built the navigator yet.
WidgetsBinding.instance.addPostFrameCallback((_) => push());
}
Then the root widget:
// lib/main.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
import 'link_navigation.dart';
void main() => runApp(const App());
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
@override
void initState() {
super.initState();
unawaited(
WarpLink.configure(
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
debugLogging: kDebugMode,
onLink: (event) {
switch (event) {
case LinkEvent(:final deepLink):
// Fires for cold start, warm start, AND deferred (first launch).
// deepLink.isDeferred tells the three apart when you need to.
openLink(deepLink);
case ErrorEvent(:final error):
debugPrint('WarpLink ${error.wireCode}: ${error.message}');
}
},
),
);
}
@override
Widget build(BuildContext context) => MaterialApp(
navigatorKey: navigatorKey,
routes: <String, WidgetBuilder>{
'/': (context) => const Placeholder(), // your home screen
'/product': (context) => const Placeholder(), // your product screen
'/web': (context) => const Placeholder(), // a web view of the destination
},
);
}
Four choices in that code are deliberate.
initState, notmain. The SDK is opt-out: a bareconfigure(apiKey:, onLink:)turns on cold-start, warm-start, and deferred links, with no stream subscriptions and no life cycle code. Call it from the root isolate, once. Background isolates are not supported.unawaited.configurereturns aFuture<void>that completes when native configuration completes. It does not wait for the first cold-start or deferred delivery, andinitStatecannot be async, so detach it on purpose.- An SDK key, not an API key.
apiKeytakes an SDK key, created under Keys > SDK keys in the dashboard. API keys are a separate credential for backend scripts, CI, and AI agents, and they cannot record installs. Both share thewl_live_prefix, so pasting an API key here is an easy mistake: deep links keep resolving, but no installs appear in your dashboard. - A bad key does not throw.
configurechecks the key format before any native call. A malformed key prints a warning, reachesonLinkas anErrorEventwithE_INVALID_API_KEY_FORMAT, and leaves the SDK unconfigured. An exception thrown by your ownonLinkwhileconfiguredelivers a rejected key or a configuration error propagates out of the future thatconfigurereturns. An exception thrown for a link that arrives later surfaces as an uncaught error and does not stop the SDK.
Step 6: Understand the Cold Start Race
The race appears the moment a link beats the first frame. The URL arrives during startup, your handler calls navigatorKey.currentState?.pushNamed(...), and currentState is still null because MaterialApp has not built its navigator. The null-aware call quietly does nothing, the app sits on the home screen, and the bug passes every test where the app was already running when you tapped.
The helper above closes it in two moves. When the navigator exists, it pushes straight away. When it does not, it registers addPostFrameCallback, which runs after the first frame has been built, and pushes from there. A link on a cold start is therefore never lost, and the back button behaves the way it would if the user had walked there, because the home route is underneath.
Two more behaviours of the delivery are worth knowing before you debug anything:
- One delivery per tap.
onLinkreceives each tap once. A second tap on the same URL within 1.5 seconds counts as the same tap and is not delivered again. If a newer tap arrives while an older one is still resolving, only the newer tap reachesonLink. - At most once. A delivered link is never offered again. A process death or an engine disposal while a tap's final step is in flight can lose that one delivery, and so can an
onLinkthat throws before it navigates.
Step 7: Route With go_router
If your app uses go_router, the same rule holds: create the router once, outside any widget, give it its own navigator key, and call it from onLink only after it has mounted. Keep Flutter's deep-linking flag off from Step 4, so the router is driven by one source.
// lib/main_go_router.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
final rootNavigatorKey = GlobalKey<NavigatorState>();
final GoRouter router = GoRouter(
navigatorKey: rootNavigatorKey,
routes: <RouteBase>[
GoRoute(path: '/', builder: (context, state) => const Placeholder()), // your home screen
GoRoute(
path: '/product/:id',
builder: (context, state) => Scaffold(
body: Center(child: Text('Product ${state.pathParameters['id']}')),
), // your product screen
),
],
);
void goTo(String location) {
if (rootNavigatorKey.currentContext != null) {
router.go(location);
return;
}
// The first frame has not mounted the router yet.
WidgetsBinding.instance.addPostFrameCallback((_) => router.go(location));
}
void main() => runApp(const App());
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
@override
void initState() {
super.initState();
unawaited(
WarpLink.configure(
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
onLink: (event) {
switch (event) {
case LinkEvent(:final deepLink):
final productId = deepLink.customParams['product_id'];
goTo(productId is String ? '/product/$productId' : '/');
case ErrorEvent(:final error):
debugPrint('WarpLink ${error.wireCode}: ${error.message}');
}
},
),
);
}
@override
Widget build(BuildContext context) =>
MaterialApp.router(routerConfig: router);
}
This sample reads the route from the link's custom parameters instead of parsing deepLinkUrl. Set product_id as a custom parameter on the link in the dashboard and your app decides where it goes, which keeps your URL structure out of the short link. customParams is a Map<String, Object?> of JSON values, so check the type before you use one, as the is String test does. Either source works. Pick one per link and stay with it.
The pattern is the same for Navigator 2, auto_route, and similar packages: keep a handle on the router, call it from onLink once the router is mounted, and keep Flutter's own handler off.
Step 8: Handle Links Yourself When You Need To
The automatic path covers most apps. When you want manual control, set automaticDeepLinks: false, or omit onLink. Then WarpLink.onDeepLink emits every URL the app receives while it runs, resolved, and WarpLink.getInitialDeepLink() returns the link that launched the app, once.
// lib/manual_mode.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
import 'link_navigation.dart';
class ManualApp extends StatefulWidget {
const ManualApp({super.key});
@override
State<ManualApp> createState() => _ManualAppState();
}
class _ManualAppState extends State<ManualApp> {
StreamSubscription<WarpLinkEvent>? _subscription;
@override
void initState() {
super.initState();
unawaited(_listen());
}
Future<void> _listen() async {
await WarpLink.configure(apiKey: 'wl_live_yoursdkkeyhere000000000000000000');
// Warm-start links (app already running).
_subscription = WarpLink.onDeepLink.listen((event) {
switch (event) {
case LinkEvent(:final deepLink):
openLink(deepLink);
case ErrorEvent(:final error):
debugPrint('Deep link error: ${error.message}');
}
});
// Cold-start link (app launched by a link). Returns null the second time.
final launchLink = await WarpLink.getInitialDeepLink();
if (launchLink != null) {
openLink(launchLink);
}
}
@override
void dispose() {
unawaited(_subscription?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) => MaterialApp(
navigatorKey: navigatorKey,
routes: <String, WidgetBuilder>{
'/': (context) => const Placeholder(),
'/product': (context) => const Placeholder(),
'/web': (context) => const Placeholder(),
},
);
}
The two streams are separate on purpose:
onLinkandonDeepLinkdo not share rules.onLinkreceives only WarpLink links, from the automatic path, with the 1.5 second rule applied.onDeepLinkreceives every URL the app is opened with, including foreign URLs, which arrive as anErrorEventcarryingE_INVALID_URL. It does not apply the 1.5 second rule. A tap resolves once, and both see the same result.getInitialDeepLinkandonLinkdo not overlap. With the automatic path on, the launch link goes toonLinkandgetInitialDeepLink()returnsnull, so no link is handled twice. IfgetInitialDeepLink()throws, call it again: the launch link is still waiting.- Early links are held. A link that arrives before the first
onDeepLinklistener attaches is delivered when it does.
Step 9: Test Every Path
Test five things, per platform: the verified https link, cold start, warm start, the link that has no in-app URL, and a URL that is not yours. Turn on debugLogging first. On iOS the native SDK prints lines prefixed with [WarpLink] in the Xcode console. On Android, run adb logcat -s WarpLink.
iOS
Test Universal Links from outside Safari's address bar. On the iOS Simulator, run xcrun simctl openurl booted https://yourapp.aplnk.to/abc123. On a physical iPhone, paste the link into Notes or Messages and tap it. Typing it into the Safari address bar does not trigger a Universal Link, by design.
# Confirm what your server actually serves
curl -sSI https://yourapp.aplnk.to/.well-known/apple-app-site-association
curl -sS https://yourapp.aplnk.to/.well-known/apple-app-site-association
# Confirm what Apple's association service cached for your host
curl -sS "https://app-site-association.cdn-apple.com/a/v1/yourapp.aplnk.to"
The header check is the one people skip and the one that finds the bug. You are looking for HTTP/2 200, content-type: application/json, and no location header anywhere in the chain. For a cold start, swipe the app away first, then tap the link.
Android
# Verified https link, letting Android decide who handles it
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://yourapp.aplnk.to/abc123"
# Cold start: stop the app first, then open the link
adb shell am force-stop com.example.myapp
# What does the system think of your domains
adb shell pm get-app-links com.example.myapp
# Force another verification attempt
adb shell pm verify-app-links --re-verify com.example.myapp
# Confirm what your server serves
curl -sS https://yourapp.aplnk.to/.well-known/assetlinks.json
Leave the package name off the am start command. Adding it targets your app directly and bypasses verification entirely, so it always works and tells you nothing about App Links. pm get-app-links prints each host with its state, and verified is the only one that counts. If you changed the fingerprint or the file, reset verification on the test device first:
adb shell pm set-app-links --package com.example.myapp 0 all
adb shell pm verify-app-links --re-verify com.example.myapp
You can also check the version the native SDK reports, which is the same value that goes on the wire, from a debug button:
// lib/debug_state.dart
import 'package:flutter/foundation.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
Future<void> logSdkState() async {
debugPrint('native SDK version: ${await WarpLink.sdkVersion()}'); // 1.1.0
debugPrint('configured: ${await WarpLink.isConfigured()}');
}
Pitfalls That Cost the Most Time
- Flutter's own handler is still on. The router shows an unknown route next to your delivery. Set both flags from Step 4 and navigate from
onLinkonly. - The fingerprint is from the wrong key. Play App Signing re-signs your app, so the upload keystore fingerprint is not the one on the device. List the app signing key, the upload key, and your debug key together.
- A redirect in front of an association file. Both platforms refuse to follow one. An apex-to-
wwwredirect, a trailing-slash normalizer, or a country redirect breaks verification with no visible error. - Universal Links do not fire from redirects. iOS only routes a link into an app on a genuine user tap. A server 302 or a script assignment lands in the browser every time.
- The user taught iOS to prefer Safari. Tapping the small breadcrumb banner after a Universal Link opens makes iOS remember the browser for that domain. Recover it by long-pressing the link and choosing Open in "YourApp", not by reinstalling.
- A delegate override without
super. Your ownAppDelegateorSceneDelegatestops Flutter's chain, and the plugin never sees the URL. - Another plugin took the launch link first (iOS). A second link or authentication plugin can handle a cold-start link before WarpLink sees it, and that is a Flutter platform limit. If the other plugin exposes the launch URL, pass it on yourself with
WarpLink.isWarpLinkUrlandWarpLink.handleDeepLink. minSdkbelow 26. The build fails with a message thatminSdkVersioncannot be smaller than the version 26 declared in the library.autoVerifyon a filter that includes a custom scheme. The whole filter fails verification. Separate blocks, always.- The intent filter sits under
<application>. It belongs inside the main<activity>. - Opening the link in the wrong place. Typing a link into the Safari address bar never triggers a Universal Link. Use
xcrun simctl openurl bootedon the Simulator, or tap the link in Notes or Messages on a device. - Forgetting the store fallback. A verified link on a device without the app is just a web page. Whatever that page does next is your responsibility, not the OS's. The deferred deep linking guide for Flutter covers the user who installs after the tap.
Frequently Asked Questions
How do I set up deep linking in Flutter?
In four layers. On iOS, add an Associated Domains entry for your link host. On Android, add an intent filter with android:autoVerify="true" inside your main activity and set minSdk to 26. Then switch off Flutter's own deep-linking flag, so the router does not also receive the URL as a route. Finally, call WarpLink.configure from your root widget's initState and route from its onLink callback. A link only opens your app when the association files, the manifest entries, and the Dart callback all agree.
Do I need to edit AppDelegate, SceneDelegate, or MainActivity in Flutter?
No. The plugin registers itself with Flutter's iOS application and scene delegate chain and attaches to the Android activity on its own, so cold start and warm start both reach it without native code. The one rule applies if your own delegate already overrides a link method: call super from it, or the plugin never sees the URL.
Why does my Flutter app show an unknown route page when I tap a link?
Flutter's own deep link handler is on by default and forwards the incoming URL to your router as a route. On an Android cold start the route can arrive without its host, so the router cannot tell a WarpLink URL from any other path. Set FlutterDeepLinkingEnabled to false in Info.plist and flutter_deeplinking_enabled to false in the manifest, then navigate from onLink alone.
Does Flutter deep linking work with go_router?
Yes. Create the GoRouter once outside any widget with its own navigator key, call WarpLink.configure from the root widget's initState, and call router.go from onLink. Keep Flutter's own deep-linking flag off so the router is driven by one source only. A cold-start link can arrive before the router mounts, so wait for the first frame before calling it.
Why does my Flutter deep link work when the app is running but not from a closed app?
Because a cold-start link can arrive before the first frame, when no navigator exists yet. A push made at that moment is dropped. Give MaterialApp a navigatorKey and, when its current state is still null, run the navigation in addPostFrameCallback. On iOS, another plugin that handles the launch link first can also hide it from WarpLink.
How do I test Flutter deep links on iOS and Android?
Test Universal Links on the iOS Simulator with xcrun simctl openurl booted https://yourapp.aplnk.to/abc123, or on a physical iPhone by tapping the link in Notes or Messages. On Android, fire the link with adb shell am start and the BROWSABLE category, confirm verification with adb shell pm get-app-links, and check both association files with curl. Turn on debugLogging and read the [WarpLink] lines in Xcode or adb logcat -s WarpLink.
Sources
- Flutter, Deep linking: Flutter's own handler, which forwards an incoming link to the router as a route.
- Flutter, Swift Package Manager for app developers: how to turn on Swift Package Manager for the iOS side of a plugin.
- Apple, Supporting associated domains: the entitlement and the
apple-app-site-associationfile. - Android Developers, Verify Android App Links: the
autoVerifyflow and theassetlinks.jsonfile. - go_router, package on pub.dev: the declarative router used in Step 7.
Related Guides
- The concepts behind all of this: The Complete Deep Linking Guide for Mobile Developers covers how Universal Links, App Links, and deferred deep links fit together.
- When iOS links open the browser: Universal Links Not Opening? Every Cause and How to Fix Each One and the file-specific Apple App Site Association Not Working: The iOS Universal Links Debug Checklist.
- When Android verification fails: Android App Links autoVerify Failed: The assetlinks.json Verification Debug Checklist.
- Routing users who do not have the app yet: Deferred Deep Linking in Flutter: One Callback for iOS and Android covers the match cascade that survives an install.
- Every command in one place: How to Test Deep Links on iOS and Android: Commands and Checklist.
- The same guide for another framework: React Native Deep Linking: Universal Links and App Links Setup Guide.
- The SDKs themselves: the SDKs page covers what the iOS, Android, React Native, and Flutter packages ship, how each one installs, and what they send.
- Reference: the Flutter SDK guide, the Flutter deep links docs, and the deep linking concept page.
How WarpLink Helps
Everything above is the platform behaviour, and it is the same whoever hosts your links. What changes is how much of it you maintain by hand. Two of the steps are pure server work with no product value: keeping an apple-app-site-association file and an assetlinks.json correct, redirect-free, and in sync with every signing key and bundle identifier change. Those are the two files that break silently and take a release cycle to notice.
WarpLink generates and hosts both from your app registration. A Flutter app is one WarpLink app that covers both platforms, so you register it once with iOS and Android selected: the bundle ID and Team ID for iOS, the package name and SHA-256 fingerprints for Android. The AASA and assetlinks.json are built and served on your link host, then regenerated whenever the app configuration changes. On the device, links resolve at the edge in under 10 milliseconds, so the tap-to-screen path stays short whichever country the user is in.
On the Dart side, the whole receive-and-route layer is the one onLink callback from Step 5. The WarpLinkDeepLink handed to it carries linkId, destination, deepLinkUrl, and customParams, plus isDeferred, matchType, matchConfidence, and matchGuaranteed. That last group is where linking turns into attribution: the same callback that puts the user on the right screen tells you which link, campaign, and channel brought them, and those installs show up in real-time analytics next to the clicks that produced them. If your links are on your own domain, pass it as linkDomains: ['links.yourapp.com'] to configure, add the matching applinks: entry and a second <data> host to the intent filter, and the SDK recognizes it on the very first launch, before it has fetched anything.
Create a free WarpLink account for deep linking, install attribution, and real-time analytics in one SDK, with 10,000 clicks a month on the free tier. The Flutter SDK guide has the full setup.
Get your first deep link working today
Deep linking, install attribution, and real-time analytics in one SDK. Free for 10K clicks a month, no sales call.
Stuck on setup? Email support@warplink.app and we will help you get one real link working.
WarpLink Team
Building affordable, reliable link infrastructure for mobile teams. Deep linking, install attribution, and real-time analytics in one SDK.
Related Posts
React Native Deep Linking: Universal Links and App Links Setup Guide
React Native deep linking takes three pieces: Universal Links on iOS, App Links on Android, and a React Navigation linking config. Set each one up end to end.
The Complete Deep Linking Guide for Mobile Developers
What is deep linking? Learn how universal links, app links, and deferred deep links work. Covers iOS, Android, and cross-platform implementation.
Deferred Deep Linking in Flutter: One Callback for iOS and Android
Deferred deep linking Flutter guide: route the first open after install with one WarpLink 1.1.0 callback, plus first-open test steps and match limits.