# What You’ll Learn#
This guide shows how to implement production-grade deep linking in Flutter using Universal Links on iOS and Android App Links on Android, then route those links reliably with go_router. It also covers deferred deep links, basic analytics attribution patterns, and a troubleshooting checklist for the most common misconfigurations.
If you’re planning an MVP, deep linking is one of the highest leverage features for growth because it reduces friction between a marketing touchpoint and the in-app action. On teams we’ve worked with, fixing broken deep links commonly increases campaign conversion rates by 10 to 30 percent because users land on the correct screen instead of a generic home view.
For related architecture decisions that affect routing and feature boundaries, see Flutter app architecture: clean architecture feature-first. If deep links are used from notifications, pair this with Flutter push notifications in production. For delivery planning and budgeting, see mobile app MVP cost.
# Deep Linking Basics That Actually Matter#
Deep links are only useful if they are predictable across these states:
- 1Cold start: app is not running and opened from a link.
- 2Warm start: app is in background and opened from a link.
- 3Foreground: app is active and receives a link.
- 4Not installed: user clicks a link and you want a correct fallback, ideally with deferred routing after install.
You also need two layers to be solid:
- OS-level verification: Universal Links and App Links require hosting verification files on your domain.
- In-app routing: once a URL arrives, your router must map it to screens with correct auth and state handling.
Universal Links vs Android App Links vs Custom Schemes#
| Capability | Universal Links (iOS) | Android App Links (Android) | Custom Scheme (both) |
|---|---|---|---|
| Uses HTTPS | Yes | Yes | No |
| Domain verification | AASA file | assetlinks.json | Not applicable |
| Opens app from browser reliably | High (when verified) | High (when verified) | Medium to low |
| Can fall back to web if app not installed | Yes | Yes | Yes |
| Common failure mode | AASA not reachable or wrong team ID | Wrong SHA-256 fingerprint | Blocked by browser or user prompt |
🎯 Key Takeaway: If you care about reliability from email, ads, and social apps, prioritize verified HTTPS links (Universal Links and Android App Links) and treat custom schemes as a secondary fallback.
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| Flutter | 3.19+ | Any recent stable is fine |
| iOS | 13+ | Universal Links behave best on modern iOS |
| Android | 6.0+ | App Links supported widely; verification improves over time |
| go_router | 13+ | Examples use typical go_router patterns |
| A domain you control | — | Needed for verification files |
# Step 1: Define Your Link Strategy and URL Contract#
Before any platform setup, define a URL contract that your backend, marketing, and mobile app can all follow.
A practical structure looks like this:
https://example.com/p/123for product detailshttps://example.com/invite/ABCDEFfor referral or team inviteshttps://example.com/reset-password?token=...for auth flows
Avoid putting sensitive data in the path. Prefer short-lived tokens and exchange them server-side.
Recommended deep link mapping table#
| URL Pattern | In-app destination | Notes |
|---|---|---|
/p/:id | ProductDetailsScreen(id) | Fetch product by id |
/invite/:code | InviteAcceptScreen(code) | Often requires auth |
/reset-password | ResetPasswordScreen(token) | Token in query, validate quickly |
/u/:username | ProfileScreen(username) | Consider slug collisions |
💡 Tip: Keep URLs stable. Renaming
/p/:idto/product/:idlater breaks old links in emails and PDFs unless you maintain redirects on the web side.
# Step 2: Flutter Packages and App Wiring#
For receiving links you typically combine:
- a link intake package such as
app_linksfor Universal Links and App Links - go_router for declarative routing and redirection
- optional analytics SDK (Firebase Analytics, Segment, Amplitude) for attribution events
Add packages#
flutter pub add go_router app_linksCreate a link handler service#
This service normalizes incoming URLs and forwards them to go_router. Keep it small and testable.
// deep_link_service.dart
import 'dart:async';
import 'package:app_links/app_links.dart';
class DeepLinkService {
final _appLinks = AppLinks();
Stream<Uri> get uriStream => _appLinks.uriLinkStream;
Future<Uri?> getInitialUri() => _appLinks.getInitialLink();
}# Step 3: Reliable In-App Routing with go_router#
go_router works best when you centralize redirection logic for auth and onboarding, then route deep links through the same rules.
Example router with deep link friendly routes#
// router.dart
import 'package:go_router/go_router.dart';
GoRouter buildRouter({
required bool isLoggedIn,
}) {
return GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/p/:id',
builder: (context, state) => ProductScreen(
id: state.pathParameters['id']!,
),
),
GoRoute(
path: '/invite/:code',
builder: (context, state) => InviteScreen(
code: state.pathParameters['code']!,
),
),
GoRoute(
path: '/reset-password',
builder: (context, state) => ResetPasswordScreen(
token: state.uri.queryParameters['token'] ?? '',
),
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
],
redirect: (context, state) {
final loggingIn = state.matchedLocation == '/login';
final requiresAuth = state.matchedLocation.startsWith('/invite');
if (requiresAuth && !isLoggedIn) {
final from = Uri.encodeComponent(state.uri.toString());
return '/login?from=$from';
}
if (isLoggedIn && loggingIn) return '/';
return null;
},
);
}Handle initial link and runtime links#
There are two cases: app opened via link, and app already running.
// deep_link_bootstrap.dart
import 'dart:async';
import 'package:go_router/go_router.dart';
class DeepLinkBootstrapper {
final GoRouter router;
final Stream<Uri> stream;
final Future<Uri?> initial;
StreamSubscription<Uri>? _sub;
DeepLinkBootstrapper({
required this.router,
required this.stream,
required this.initial,
});
Future<void> start() async {
final first = await initial;
if (first != null) {
router.go(first.path + (first.hasQuery ? '?${first.query}' : ''));
}
_sub = stream.listen((uri) {
router.go(uri.path + (uri.hasQuery ? '?${uri.query}' : ''));
});
}
Future<void> dispose() async {
await _sub?.cancel();
}
}⚠️ Warning: Do not route to a deep link before your app state is ready. If you compute
isLoggedInasynchronously, build the router with a synchronous state holder and trigger refresh when auth finishes, otherwise you’ll get redirect loops or incorrect screens.
# Step 4: iOS Universal Links Setup#
Universal Links require two things:
- 1Enable Associated Domains in Xcode.
- 2Host an Apple App Site Association file on your domain.
4.1 Configure Associated Domains#
In Xcode:
- Target settings
- Signing and Capabilities
- Add capability: Associated Domains
- Add entries such as:
applinks:example.comapplinks:links.example.com
If you have multiple environments, use subdomains. Avoid trying to share a single domain across dev and prod with different bundle IDs unless you control routing carefully.
4.2 Host the AASA file#
The file must be available at one of these paths:
https://example.com/apple-app-site-associationhttps://example.com/.well-known/apple-app-site-association
It must be served as JSON with no redirects. Many teams break this by forcing a 301 from non-www to www. iOS can fail the fetch and you will silently fall back to Safari.
Example AASA:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.example.app",
"paths": ["/p/*", "/invite/*", "/reset-password*"]
}
]
}
}Where:
TEAMIDis your Apple Team IDcom.example.appis your iOS bundle ID
4.3 Validate on device#
After installing the app, test by tapping a link in Notes or Messages. If it opens Safari, verification likely failed.
Practical checks:
- Confirm the AASA is reachable in a normal browser at the exact path.
- Confirm the response is 200 and not redirected.
- Confirm the content matches your team ID and bundle ID.
ℹ️ Note: Universal Links are cached by iOS. After fixing AASA, you may need to delete the app, restart the device, and reinstall to force a fresh verification.
# Step 5: Android App Links Setup#
Android App Links require:
- 1Intent filters in your AndroidManifest.xml.
- 2A Digital Asset Links file on your domain.
- 3Correct signing fingerprints for the build variant you are testing.
5.1 Add intent filters#
In android/app/src/main/AndroidManifest.xml under your main activity:
<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="example.com"
android:pathPrefix="/p" />
</intent-filter>Repeat for other paths or use a broader filter:
- multiple intent filters per host and pathPrefix
- or one filter per host and accept all paths if your app handles unknown paths safely
5.2 Host assetlinks.json#
Place it at:
https://example.com/.well-known/assetlinks.json
Example:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:...:ZZ"
]
}
}
]The fingerprint must match the signing certificate of the app installed on the device. This differs for:
- debug builds
- local release builds
- Play Store builds
- Play App Signing keys
5.3 Verify with adb#
Use Android’s built-in verification tools:
adb shell pm get-app-links com.example.app
adb shell am start -a android.intent.action.VIEW -d "https://example.com/p/123"If verification fails, Android will usually open the browser instead of the app, or show a chooser on some devices.
# Step 6: Deferred Deep Links (When the App Is Not Installed)#
Deferred deep linking means:
- user taps
https://example.com/invite/ABCDEF - they install the app
- the app opens and navigates to InviteAcceptScreen with code
ABCDEF
This is harder than direct deep links because the OS does not automatically pass the original URL through the store install flow.
Options in 2026#
| Option | Reliability | Setup complexity | Notes |
|---|---|---|---|
| Your own “install then claim” flow | Medium | Medium | Requires backend + clipboard or user login handshake |
| Third-party attribution provider | High | Medium to high | AppsFlyer, Adjust, Branch commonly used |
| Firebase Dynamic Links | Deprecated for new projects | Low to medium | Avoid starting new long-lived implementations |
A practical approach for many products is: do not chase perfect deferred deep linking on day one. Make sure the fallback web page captures the intent and provides a “Continue in app after install” path.
A workable pattern without a vendor#
- 1Link opens a landing page like
/invite/ABCDEF. - 2The landing page stores the code server-side associated with an anonymous session ID.
- 3The landing page offers “Open app” and “Install app”.
- 4After install and first launch, the app asks your backend “do we have a pending invite for this session ID” using a one-time token that the landing page can pass via a query to the store, or via an email login step.
This is not as seamless as vendor solutions, but it is measurable and avoids lock-in.
💡 Tip: For invites and referrals, you can often accept “one extra step” without losing most conversions if the landing page is fast. Aim for a Largest Contentful Paint less than 2.5 seconds and keep the CTA above the fold.
# Step 7: Analytics and Attribution Basics#
Deep links are only valuable if you can measure what they did. At minimum, log:
- deep link opened
- deep link routed to screen
- deep link conversion event
What to capture from the URL#
| Field | Example | Why it matters |
|---|---|---|
| Full URL | https://example.com/p/123?utm_source=... | Debugging and attribution |
| Path | /p/123 | Routing and content grouping |
| Query params | utm_source, utm_campaign, ref | Marketing performance |
| Timestamp | ISO string | Session stitching |
| Install state | installed or not | Deep link vs deferred |
Example: extract and log UTM parameters#
Map<String, String> extractUtm(Uri uri) {
final qp = uri.queryParameters;
return {
'utm_source': qp['utm_source'] ?? '',
'utm_medium': qp['utm_medium'] ?? '',
'utm_campaign': qp['utm_campaign'] ?? '',
'utm_content': qp['utm_content'] ?? '',
};
}Then log events like:
deep_link_openeddeep_link_routedinvite_accepted
Keep naming consistent and stable, otherwise dashboards become unusable.
# Troubleshooting: Common Issues and Edge Cases#
These are the failures we see most often in production launches.
Links open the browser instead of the app#
iOS checks
| Check | Expected | Fix |
|---|---|---|
| AASA reachable | 200 OK, no redirect | Remove redirects, serve from root or .well-known |
| appID matches | TEAMID and bundle ID correct | Update AASA details |
| Associated Domains | applinks:example.com added | Enable capability in Xcode |
| Caching | iOS caches verification | Delete app, reboot device, reinstall |
Android checks
| Check | Expected | Fix |
|---|---|---|
| assetlinks path | /.well-known/assetlinks.json | Place file correctly |
| Fingerprint | matches installed app signature | Use correct debug or release SHA-256 |
| Manifest | intent-filter includes host and scheme | Add filter with autoVerify |
| Verification status | verified in system | Use pm get-app-links to inspect |
Deep link opens the app but routes to the wrong screen#
Common causes:
- 1You route using
uri.toString()but your router expects only the path. - 2You are using
gowhen you neededpush, or vice versa. - 3Auth redirect overrides the deep link and you do not return after login.
A robust pattern is to preserve the intended destination in a from query, then redirect after successful login.
String? fromAfterLogin(Uri loginUri) {
final from = loginUri.queryParameters['from'];
return from == null || from.isEmpty ? null : Uri.decodeComponent(from);
}Duplicate screens or weird back navigation#
This often happens when:
- you call
router.gorepeatedly for the same URI - your link listener triggers twice on Android due to activity recreation
- you mix imperative Navigator calls with go_router
Mitigations:
- debounce identical URIs within 300 to 500 ms
- use go_router everywhere, avoid raw Navigator in feature screens
- prefer
gofor “this link defines the current location” andpushfor “open on top of current flow”
Universal Links work in Messages but not in Gmail or Slack#
Some apps open links in their own in-app browser, which can change link handling. Universal Links and App Links are most reliable from system apps and full browsers, but the behavior varies by app and OS version.
Practical solution:
- ensure the web fallback page is good
- include an explicit “Open in app” button that uses a custom scheme as a fallback for in-app browsers
Be aware that custom schemes can be blocked or require user confirmation, so treat them as a best-effort.
Push notification deep links conflict with normal deep links#
If you also navigate from notifications, unify both into the same routing contract. Your notification payload should carry a URL, not an ad-hoc screen name. This keeps the logic consistent and testable.
For a production notification setup, see Flutter push notifications in production.
# Key Takeaways#
- Use verified HTTPS links for reliability: Universal Links on iOS and Android App Links on Android, with correct AASA and assetlinks.json hosting.
- Treat deep links as a URL contract owned by product and engineering, then map URL patterns to go_router routes with consistent redirects.
- Handle both initial links and link streams, and delay routing until auth and app state are ready to avoid redirect loops.
- For deferred deep links, either use an attribution vendor or implement a measurable “install then claim” flow with a strong web fallback.
- Instrument deep link events and UTM parameters so marketing and product can measure conversions end-to-end.
- Debug systematically with device tools: iOS caching behavior, Android
pm get-app-links, and signature fingerprints per build variant.
# Conclusion#
Flutter deep linking is not just about opening the app; it’s about landing the user on the correct screen across cold start, background, and foreground, with measurable attribution. If you implement domain verification correctly and centralize routing with go_router, you eliminate the most common sources of broken links and lost conversions.
If you want Samioda to set up deep linking end-to-end, including deferred deep links, analytics attribution, and a routing architecture that scales with features, contact us and we’ll review your current configuration and ship a production-ready implementation in a predictable sprint.
FAQ
More in Mobile Development
All →Flutter CI/CD in 2026: GitHub Actions vs Codemagic vs Fastlane (With a Production Pipeline Blueprint)
A practical 2026 guide to Flutter CI/CD: compare GitHub Actions, Codemagic, and Fastlane, then implement a production-ready pipeline with flavors, signing, build numbers, tests, code generation, caching, and store deployments.
Flutter Push Notifications in Production: FCM + APNs, Deep Links, and Reliability (2026 Guide)
An end-to-end production guide to Flutter push notifications using FCM and APNs: setup, token lifecycle, segmentation, deep linking, background and terminated handling, plus reliability and troubleshooting checklists.
Flutter Offline-First Apps: Local Storage, Sync Strategies, and Conflict Resolution
Build reliable offline-first Flutter apps with robust local persistence, background sync, and conflict resolution. Includes a reference architecture and a practical testing checklist for flaky networks.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Flutter Push Notifications in Production: FCM + APNs, Deep Links, and Reliability (2026 Guide)
An end-to-end production guide to Flutter push notifications using FCM and APNs: setup, token lifecycle, segmentation, deep linking, background and terminated handling, plus reliability and troubleshooting checklists.
Flutter Performance Optimization: How We Keep Apps at 60fps (Profiling + Fixes)
A repeatable Flutter performance optimization workflow using DevTools to diagnose jank, then apply targeted fixes: rebuild reduction, rendering improvements, image pipelines, and isolates — with budgets and checklists.
Flutter App Architecture That Scales: Clean Architecture vs Feature-First (With Real Folder Structures)
A practical guide to Flutter app architecture in 2026: compare Clean Architecture and Feature-First, see real folder structures, dependency boundaries, and testing strategies, and choose the right approach for your team and release cadence.