deep-linking

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.

WarpLink Team··25 min read

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 set minSdk to 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.configure called from the root widget's initState wires cold start, warm start, and the first-launch deferred check into one onLink callback, with no AppDelegate, SceneDelegate, or MainActivity edits. 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 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 typeLooks likeVerified by the OSWorks without the app
Custom URL schememyapp://product/42NoNo. The tap dead-ends
Universal Link (iOS)https://yourapp.aplnk.to/abc123Yes, through apple-app-site-associationYes. Opens the web page
App Link (Android)https://yourapp.aplnk.to/abc123Yes, through assetlinks.jsonYes. 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

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:

There is no Podfile change and no pod install step. Android links automatically through Gradle.

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:

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:

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 .json extension 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 put exclude rules 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:

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.

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:

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>:

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:

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 run signs with:

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:

On Android, inside the <activity> in AndroidManifest.xml:

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:

Then the root widget:

Four choices in that code are deliberate.

  • initState, not main. The SDK is opt-out: a bare configure(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. configure returns a Future<void> that completes when native configuration completes. It does not wait for the first cold-start or deferred delivery, and initState cannot be async, so detach it on purpose.
  • An SDK key, not an API key. apiKey takes 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 the wl_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. configure checks the key format before any native call. A malformed key prints a warning, reaches onLink as an ErrorEvent with E_INVALID_API_KEY_FORMAT, and leaves the SDK unconfigured. An exception thrown by your own onLink while configure delivers a rejected key or a configuration error propagates out of the future that configure returns. 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. onLink receives 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 reaches onLink.
  • 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 onLink that 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.

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.

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.

The two streams are separate on purpose:

  • onLink and onDeepLink do not share rules. onLink receives only WarpLink links, from the automatic path, with the 1.5 second rule applied. onDeepLink receives every URL the app is opened with, including foreign URLs, which arrive as an ErrorEvent carrying E_INVALID_URL. It does not apply the 1.5 second rule. A tap resolves once, and both see the same result.
  • getInitialDeepLink and onLink do not overlap. With the automatic path on, the launch link goes to onLink and getInitialDeepLink() returns null, so no link is handled twice. If getInitialDeepLink() throws, call it again: the launch link is still waiting.
  • Early links are held. A link that arrives before the first onDeepLink listener 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.

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

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:

You can also check the version the native SDK reports, which is the same value that goes on the wire, from a debug button:

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 onLink only.
  • 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-www redirect, 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 own AppDelegate or SceneDelegate stops 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.isWarpLinkUrl and WarpLink.handleDeepLink.
  • minSdk below 26. The build fails with a message that minSdkVersion cannot be smaller than the version 26 declared in the library.
  • autoVerify on 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 booted on 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

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