Deep Linking in Flutter:What Actually Works

Flutter guide

Deep Linking in Flutter:
The Complete Breakdown

Deep linking in Flutter does not work out of the box. Here is the complete setup for getting users from a confirmation email back into your app, including the parts that break without any error to trace.

We were building a session booking app. The auth flow looked straightforward: user signs up, gets a confirmation email, clicks the link, lands inside the app ready to book. In practice, clicking the link opened a browser, the user saw a generic redirect page, the session token was gone, and they had no idea what to do next. We later wrote up the full project in our booking app development case study, but the deep link handling was the part that nearly derailed the launch.

Deep linking in Flutter: laptop with app code open during setup

The problem was not a bug in our code. Flutter gives you the UI layer and nothing else. Deep linking in Flutter requires platform configuration on both iOS and Android, a small piece of web infrastructure on your domain, and Dart code to handle the incoming URL. Most tutorials cover one or two of these pieces. This post covers all of them, and the official Flutter deep linking documentation is the reference for anything we do not repeat here.

the problem

Deep linking in Flutter: two types of links, one right choice

There are two kinds of links that can open a mobile app, and they behave very differently.

URI schemes like myapp://auth/callback work between apps on the same device but email clients do not trust them. They are useful for local development and nothing else.

Universal Links on iOS and App Links on Android are real https:// URLs on a domain you control. The OS checks whether an app is registered for that domain and opens it directly. If the app is not installed, the browser handles the URL normally. These are what email clients send, and what Supabase auth emails use.

The setup requires three layers to work together: platform configuration in the app, a verification file on your domain, and a redirect page that completes the flow when the direct link cannot fire.

auth provider

Configure Supabase and enable PKCE

In the Supabase dashboard under Authentication, set your redirect URL to a real domain you control. Keep the custom scheme as a secondary option for local development only.

Supabase Dashboard / Authentication / URL Configuration
1Site URL: https://yourdomain.com 2Redirect URLs: https://yourdomain.com/auth/callback 3 com.yourapp://auth/callback (dev only)

Initialize Supabase with the PKCE auth flow. Without PKCE, tokens arrive in the URL fragment after a # character. Gmail on Android strips URL fragments before opening links. PKCE puts the auth code in a query parameter instead, which survives every email client we have tested.

main.dart
1await Supabase.initialize( 2 url: 'https://your-project.supabase.co', 3 anonKey: 'your-anon-key', 4 authOptions: FlutterAuthClientOptions( 5 authFlowType: AuthFlowType.pkce, 6 ), 7);
android

Android App Links setup for Flutter

Intent filter

Add an intent filter inside the MainActivity activity block in AndroidManifest.xml. The android:autoVerify="true" attribute tells Android to verify your domain ownership so it opens the app directly without showing a disambiguation dialog.

android/app/src/main/AndroidManifest.xml
1<intent-filter android:autoVerify="true"> 2 <action android:name="android.intent.action.VIEW" /> 3 <category android:name="android.intent.category.DEFAULT" /> 4 <category android:name="android.intent.category.BROWSABLE" /> 5 <data android:scheme="https" 6 android:host="yourdomain.com" 7 android:pathPrefix="/auth/callback" /> 8</intent-filter>

Digital Asset Links file

Android will not open the app from https:// links without this verification file hosted at your domain. If the file is missing or incorrect, Android falls back to the browser with no error shown anywhere.

https://yourdomain.com/.well-known/assetlinks.json
1[{ 2 "relation": ["delegate_permission/common.handle_all_urls"], 3 "target": { 4 "namespace": "android_app", 5 "package_name": "com.yourapp.id", 6 "sha256_cert_fingerprints": ["AA:BB:CC:..."] 7 } 8}]
Watch out Use the release keystore fingerprint, not the debug one. They are different values and App Links will verify correctly in debug builds but silently fall back to the browser in every production install. Run keytool -list -v -keystore your-release.keystore to get the correct SHA-256 value. You can add both fingerprints to the array to cover both environments.
ios

iOS Universal Links setup for Flutter

Entitlements

In Xcode, select your target, go to Signing and Capabilities, and add the Associated Domains capability. Register your domain with the applinks: prefix:

ios/Runner/Runner.entitlements
1<key>com.apple.developer.associated-domains</key> 2<array> 3 <string>applinks:yourdomain.com</string> 4</array>

Apple App Site Association

Host this JSON file at your domain. Note there is no .json extension in the filename. Apple fetches this file when the app is installed, not at runtime, so any changes you make take effect on the next install.

https://yourdomain.com/.well-known/apple-app-site-association
1{ 2 "applinks": { 3 "details": [{ 4 "appIDs": ["TEAMID.com.yourapp.id"], 5 "components": [{ "/": "/auth/callback*" }] 6 }] 7 } 8}
iOS Simulator limitation Universal Links do not work on the iOS Simulator at all. This is a platform limitation with no workaround. You need a physical device or a TestFlight build to test the complete flow. Budget time for this during development.
web bridge

The redirect page most tutorials skip

When the user clicks the email link, the OS fires the Universal or App Link and opens the app directly if it is installed and the domain has been verified. If not, the browser opens instead.

You need a small HTML page at /auth/callback that reads the incoming parameters and redirects to the app via its URI scheme. This is the piece most tutorials skip, and without it the flow breaks the moment a user clicks the link on a device where verification has not completed yet.

public/auth/callback/index.html
1<script> 2 // With PKCE the auth code arrives as a query param, not a fragment 3 const params = window.location.search; 4 5 if (params) { 6 window.location.href = 'com.yourapp://auth/callback' + params; 7 } else { 8 window.location.href = '/login?error=invalid_link'; 9 } 10</script>

The complete flow with this page in place:

  1. ●User clicks link in confirmation email
  2. ●OS checks if an app is registered for yourdomain.com
  3. YApp opens directly with the URI and code parameter
  4. NBrowser opens /auth/callback?code=...
  5. HTML page redirects to com.yourapp://auth/callback?code=...
  6. OS opens app via URI scheme
  7. ●Flutter app_links catches the incoming URI
  8. ●Supabase.auth.getSessionFromUrl(uri) stores the session
  9. ●Auth state changes, router navigates to home screen
dart code

Handling deep linking in Flutter code

Add app_links to your pubspec.yaml dependencies. Starting from app_links v6, uriLinkStream covers both cold start and warm start cases, so you only need one listener. Call it as early as possible in your app lifecycle, before any navigation happens.

auth_deep_link_handler.dart
1class AuthDeepLinkHandler { 2 final _appLinks = AppLinks(); 3 StreamSubscription<Uri>? _sub; 4 5 void init() { 6 // uriLinkStream covers cold start and warm start in app_links v6+ 7 _sub = _appLinks.uriLinkStream.listen(_handle); 8 } 9 10 Future<void> _handle(Uri uri) async { 11 if (uri.path.contains('/auth/callback')) { 12 await Supabase.instance.client.auth.getSessionFromUrl(uri); 13 } 14 } 15 16 void dispose() => _sub?.cancel(); 17}

Connect the router to Supabase auth state so navigation reacts automatically when a session is established. The example below uses go_router with a refresh stream wrapper:

router.dart
1final router = GoRouter( 2 refreshListenable: GoRouterRefreshStream( 3 Supabase.instance.client.auth.onAuthStateChange, 4 ), 5 redirect: (context, state) { 6 final session = Supabase.instance.client.auth.currentSession; 7 if (session == null) return '/login'; 8 if (state.matchedLocation.startsWith('/auth')) return '/home'; 9 return null; 10 }, 11 routes: [ /* your routes */ ], 12);

The same handler covers links beyond login. Once auth works, product links such as a shared booking, an invitation to a team or a password reset follow the same path: the domain opens the app, app_links delivers the URI, and the router maps its path to a screen. Keep one list of supported paths in the router and send anything unknown to the home screen, so an old link from last year's email never lands on an empty page. If a path needs a signed in user, store the target before redirecting to login and resume it once the session exists. That small detail is what makes deep linking in Flutter feel reliable to users, because a shared link always ends where the sender meant it to.

Initialization order matters Subscribe to uriLinkStream before calling runApp(). If you wait until after the widget tree builds, you risk missing the cold start link on Android. Also make sure await Supabase.initialize() completes first, otherwise getSessionFromUrl will be called on an unready client and fail silently.
silent failures

Deep linking in Flutter: the things that break silently

These are the failures we hit during testing. None of them produce a useful error message. We pulled together more of these hard won lessons in our writeup on mobile auth lessons from 10 apps, if you want the broader picture.

silent failure Deep link handler initialized too late on cold start
If you initialize AppLinks after your first frame renders, the stream subscription can miss the initial link entirely on some Android devices. The app opens normally with no deep link handled.
Initialize AppLinks and subscribe to uriLinkStream before runApp(), or as early as possible in main()
silent failure Android works in debug, breaks in production
The assetlinks.json was set up using the debug keystore fingerprint. Release builds use a different signing key. Android App Links verifies correctly in debug and silently falls back to the browser in every production install.
Add both debug and release SHA-256 fingerprints to assetlinks.json
silent failure iOS Universal Links only work on a real device
The iOS Simulator does not support Universal Links at all. This is a platform limitation, not a configuration problem. No entitlement change or AASA file adjustment will make it work in the Simulator. You need a physical device or a TestFlight build.
Test the complete auth flow on a physical device from the start
silent failure Gmail on Android strips URL fragments
Without PKCE, Supabase sends the session tokens in the URL fragment after a # character. Gmail on some Android versions removes the fragment before opening the link. The redirect page receives a URL with no tokens and the user sees a generic error with no context.
Use AuthFlowType.pkce so the auth code travels as a query parameter instead

After this setup the flow worked cleanly across iOS and Android, cold start and warm start, Gmail, Apple Mail, and Outlook. The testers who had been landing on blank redirect pages were opening the app directly to the booking screen, authenticated and ready to go.

None of the individual pieces of deep linking in Flutter are complicated. The challenge is that they all have to be right at the same time, and when something is wrong the failure is usually silent. If you are scoping a project like this and want a sense of the numbers involved, our guide to mobile app development cost breaks down what a build like this typically takes, and our Flutter app development company page shows how we run complete Flutter projects end to end.

testing

How to test deep linking in Flutter before release

Every failure above is silent, so testing has to be deliberate. Clicking a link in your own inbox once is not a test. These are the checks we run on every project before a build goes to the stores.

Android

Fire the link directly from the command line instead of from an email. This shows whether the app opens the URL at all, without any email client in the way:

terminal
1adb shell am start -a android.intent.action.VIEW \ 2 -d "https://yourdomain.com/auth/callback?code=test" 3 4# Check whether Android verified your domain 5adb shell pm get-app-links com.yourapp.id

The second command lists the domains the system has verified for your package. If your domain shows anything other than verified, the problem is in assetlinks.json or the fingerprint inside it, not in your Dart code. Run it on a release build, because that is the signing key your users actually get.

iOS

Install a TestFlight build on a physical iPhone, paste the link into the Notes app and tap it. Links typed into the Safari address bar do not trigger Universal Links, so that test always looks like a failure even when the setup is correct.

If the app does not open, check what Apple actually fetched. Apple serves the association file through its own CDN, and you can request it at https://app-site-association.cdn-apple.com/a/v1/yourdomain.com. If that response is empty or out of date, the device never received your configuration, and reinstalling the app after the CDN updates is what picks up the change.

The full flow

Finally, send real confirmation emails to Gmail, Apple Mail and Outlook accounts and open each one on both platforms, once with the app installed and once without it. That matrix is where the redirect page earns its place, and it takes less than an hour on a project where deep linking in Flutter sits on the login path.

faq

Deep linking in Flutter: FAQ

Does deep linking in Flutter work without extra setup?

No. Flutter handles the screens, but deep linking needs platform configuration on Android and iOS, verification files on your domain and Dart code that reads the incoming URL. Without all of them, links open the browser instead of the app.

What is the difference between URI schemes and Universal Links?

A URI scheme such as myapp:// works between apps on one device, but email clients do not trust it. Universal Links on iOS and App Links on Android are normal https links on your own domain, which the system opens in the app when it is installed.

Which package should I use for deep linking in Flutter?

We use app_links. From version 6 its uriLinkStream covers both cold start and warm start, so one listener is enough, as long as you subscribe before runApp().

How do I test deep linking in Flutter on a real device?

On Android, fire the link with adb shell am start and check domain verification with adb shell pm get-app-links on a release build. On iOS, install a TestFlight build on a physical iPhone and tap the link from the Notes app, because the Simulator and the Safari address bar do not trigger Universal Links.

Why do deep links work in debug but not in production?

On Android the most common cause is a debug keystore fingerprint in assetlinks.json. Release builds are signed with a different key, so add the release SHA-256 fingerprint as well. On iOS, Universal Links never work in the Simulator, so test on a real device.