Deep Linking in Flutter: From Tap to Right Screen
Universal Links (iOS) and App Links (Android) require an HTTPS-hosted association file — without it, links open in the browser, not your app
Deep Linking in Flutter: From Tap to Right Screen

Key takeaways
- Universal Links (iOS) and App Links (Android) require an HTTPS-hosted association file — without it, links open in the browser, not your app.
- The
apple-app-site-associationfile must be served fromhttps://yourdomain.com/.well-known/withContent-Type: application/jsonand no redirects. - Android needs both an intent filter with
android:autoVerify="true"and a validassetlinks.jsonwith the SHA-256 of your signing cert. - Cold-start deep links arrive via
getInitialLink(); in-app links arrive via the stream — handle both or you'll miss half your traffic. - Don’t gate the deep-link handler behind auth — capture the intended URL, route through auth, then replay the original destination.
A deep link is a URL that opens a specific place in your app. Tap an https://yourapp.com/threads/abc link in an email, and instead of the browser opening, your app launches and lands the user on thread abc. Tap a notification with a similar payload from the OS, same thing.
Done right, deep links feel like teleportation. Done wrong, they bounce the user to your home screen, lose the original intent, or open in a browser tab while the app sulks in the background. The plumbing is non-trivial, but it’s well-understood.
Two technologies, one effect
There are two flavors of URL that can open your app:
- Custom scheme:
myapp://thread/abc. Old-school, easy to set up, but no verification — any app can register the same scheme. - HTTPS deep links:
https://yourapp.com/thread/abc. Modern, verified by domain ownership, falls back gracefully to web.
For new apps, HTTPS is the default choice. The verification means a malicious app can’t hijack your URLs. The web fallback means users without the app installed see your marketing page instead of a broken link.
On iOS, HTTPS deep links are called Universal Links. On Android, they’re called App Links. The configuration is platform-specific; the Dart code is mostly platform-agnostic.
The Flutter package
dependencies:
app_links: ^latest
app_links is the modern, actively maintained replacement for uni_links. It handles both schemes and HTTPS, both cold-start and warm-start.
final appLinks = AppLinks();
// Cold start — app was killed when the link was tapped
final initialUri = await appLinks.getInitialLink();
if (initialUri != null) _handleDeepLink(initialUri);
// Warm start — app was running
appLinks.uriLinkStream.listen((uri) {
_handleDeepLink(uri);
});
Two listeners, one handler. The handler parses the URI and navigates:
void _handleDeepLink(Uri uri) {
final segments = uri.pathSegments;
if (segments.length >= 2 && segments[0] == 'thread') {
final threadId = segments[1];
navigatorKey.currentState?.pushNamed('/threads/$threadId');
}
}
For more structured routing, hand the URI to your router (go_router supports this naturally — every deep link is a route push).
iOS configuration
Two pieces:
- Associated Domains entitlement. In Xcode: Signing & Capabilities → Associated Domains → add
applinks:yourapp.com. - AASA file on your server. At
[https://yourapp.com/.well-known/apple-app-site-association:](https://yourapp.com/.well-known/apple-app-site-association:)
{
"applinks": {
"details": [{
"appIDs": ["TEAMID.com.yourapp.bundle"],
"components": [{ "/": "/thread/*" }, { "/": "/invite/*" }]
}]
}
}
Serve as application/json with no .json extension. iOS fetches this on app install to verify your app owns the domain.
- For custom scheme (
myapp://): add toInfo.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array><string>myapp</string></array>
</dict>
</array>
Android configuration
Two pieces:
- Intent filters in
AndroidManifest.xml:
<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.com" android:pathPrefix="/thread/" />
</intent-filter>
android:autoVerify="true" is critical — it tells Android to use the assetlinks.json verification.
- assetlinks.json at
[https://yourapp.com/.well-known/assetlinks.json:](https://yourapp.com/.well-known/assetlinks.json:)
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourapp",
"sha256_cert_fingerprints": ["AB:CD:..."]
}
}]
Get the SHA-256 from your signing config. Note: the sha256_cert_fingerprints must match the actual signing key used in the APK/AAB on the user's device, including Play Signing if you use it.
The cold-start vs warm-start distinction
A user taps a link in two scenarios:
- App is killed → “cold start.” The OS launches the app and passes the URL via initial-app-link APIs.
getInitialLink()returns it once. - App is backgrounded or already running → “warm start.” The OS sends the URL as an event.
uriLinkStreamemits it.
You must handle both. Cold-start is one-shot; warm-start is a stream that may fire repeatedly.
For your initial routing to be correct on cold start, fetch the link before you decide your initial route:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final initial = await AppLinks().getInitialLink();
runApp(MyApp(initialUri: initial));
}
In MyApp, the initial uri sets the start route. Without this, users get dropped on home, then your stream listener fires and pushes — they see a flicker.
Auth-gated deep links
If a deep link requires authentication (“view thread X” when X is a private thread), you have two options:
- Check auth first. If logged in, navigate. If not, store the deep link, show sign-in, navigate after auth.
- Always navigate, let the destination handle auth. The thread screen shows a sign-in prompt if the user isn’t logged in.
Option 1 is the cleaner UX for most apps. Implement with a “pending deep link” field in your auth controller:
class AuthGate extends StatelessWidget {
@override
Widget build(BuildContext context) {
final auth = ref.watch(authProvider);
if (auth.signedIn) {
final pending = ref.read(pendingDeepLinkProvider);
if (pending != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(pendingDeepLinkProvider.notifier).state = null;
navigate(pending);
});
}
return HomeScreen();
}
return SignInScreen();
}
}
Universal Links via Firebase Dynamic Links (deprecated)
Firebase Dynamic Links used to be the recommended way to handle deep links with deferred install (a URL that, when tapped by a user without the app installed, takes them to the store, installs the app, and then opens the right screen). Dynamic Links is being shut down in 2025. Don’t build new apps on it.
Replacements:
- AppsFlyer, Branch.io — paid attribution services with deferred deep linking.
- Manual deferred deep linking — store the URL server-side keyed by an install-time identifier, retrieve after install.
For pure intra-app routing without the install-time complexity, plain Universal Links + App Links cover you.
Testing deep links
- iOS:
xcrun simctl openurl booted "https://yourapp.com/thread/abc". Test on a real device too — Universal Links don't work in some simulator scenarios. - Android:
adb shell am start -a android.intent.action.VIEW -d "https://yourapp.com/thread/abc".
Test both with the app killed and with the app foregrounded. Test by tapping the URL in Notes/Messages — both iOS Universal Links and Android App Links require a user-initiated cross-app navigation. Pasting the URL into the browser address bar bypasses verification on both platforms.
Things to watch
- Universal Links / App Links won’t work from the browser address bar. They work from other apps (Mail, Messages). Both iOS and Android require a user-initiated cross-app navigation — typing or pasting into the browser bypasses verification on both platforms.
- Caching of AASA / assetlinks.json. iOS and Android cache verification results. If you change them, reinstall the app to refetch.
- HTTPS requirement. Both AASA and assetlinks files must be served over HTTPS with valid certificates.
- Path patterns must match exactly. A URL like
/threads/abcwon't match a config of/thread/*(singular vs plural).
The takeaway
Deep linking is a feature where the configuration is harder than the code. Once the AASA file, intent filters, and assetlinks file are correct, the Flutter side is twenty lines of route handling. The payoff is users land where they expected — from emails, notifications, shared links, search results.
Spend a focused afternoon getting deep links working end-to-end, including the auth-gated case and the cold-start case. After that, every new link you publish is just a route in your router. The infrastructure work compounds across the rest of the app’s lifetime.
Frequently asked questions
Why does my Flutter Universal Link open Safari instead of the app?
The most common cause is a misconfigured apple-app-site-association file — wrong content type, served with a redirect, missing the appID, or the app's Associated Domains entitlement doesn't match. Apple validates this on first install; rebuild after every AASA change.
How do I test Android App Link verification?
Run adb shell pm get-app-links your.package.name. The output shows verified domains and any failure reasons. If verification fails, your assetlinks.json SHA-256 doesn't match the signing key the device sees.
Should I use go_router, auto_route, or manual Navigator for deep links?
go_router has built-in URI parsing and integrates with the platform's link APIs cleanly. auto_route is the next most popular choice. Manual Navigator.pushNamed works but you'll write more code to handle the cold-start case. For new projects, go_router is the default.
What happens to a deep link when the user isn’t logged in?
Capture the link in a “pending intent” buffer, route the user through auth, then replay the original URL. Never drop the link silently — that’s the most common UX failure in deep-link implementations.
Can I deep-link into a specific tab and sub-screen at the same time?
Yes — design your route hierarchy so URLs map to nested locations (e.g. /home/orders/123). go_router's StatefulShellRoute handles this cleanly with persistent tab state.
Enjoyed this article?
If this saved you some debugging time or sparked an idea for your next Flutter project, hit the clap button below. You can clap up to 50 times — every clap helps more developers find this piece, and tells me which topics to dig deeper into next.
Got a question, a different take, or a Flutter horror story this reminded you of? Drop it in the responses. I read every one.
Follow along for more practical Flutter writeups — one widget, one pitfall, one production lesson at a time. 👏
메타데이터
- post_id
- 7bc3569debfa
- slug
- deep-linking-in-flutter-from-tap-to-right-screen-7bc3569debfa
- url
- https://medium.com/@himanshusharma_4140/deep-linking-in-flutter-from-tap-to-right-screen-7bc3569debfa
- canonical_url
- https://medium.com/@himanshusharma_4140/deep-linking-in-flutter-from-tap-to-right-screen-7bc3569debfa
- author_url
- https://medium.com/@himanshusharma_4140
- status
- ok
- fetched_at
- 2026-06-15 20:49:13