Mobile Development
FlutterMobile DevelopmentDeep LinkingUniversal LinksAndroid App Linksgo_routerAnalytics

Flutter Deep Linking Guide for 2026: Universal Links, Android App Links, and Reliable In-App Routing

AO
Adrijan Omićević
·14 min read

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

  1. 1
    Cold start: app is not running and opened from a link.
  2. 2
    Warm start: app is in background and opened from a link.
  3. 3
    Foreground: app is active and receives a link.
  4. 4
    Not 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.
CapabilityUniversal Links (iOS)Android App Links (Android)Custom Scheme (both)
Uses HTTPSYesYesNo
Domain verificationAASA fileassetlinks.jsonNot applicable
Opens app from browser reliablyHigh (when verified)High (when verified)Medium to low
Can fall back to web if app not installedYesYesYes
Common failure modeAASA not reachable or wrong team IDWrong SHA-256 fingerprintBlocked 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#

RequirementVersionNotes
Flutter3.19+Any recent stable is fine
iOS13+Universal Links behave best on modern iOS
Android6.0+App Links supported widely; verification improves over time
go_router13+Examples use typical go_router patterns
A domain you controlNeeded for verification files

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/123 for product details
  • https://example.com/invite/ABCDEF for referral or team invites
  • https://example.com/reset-password?token=... for auth flows

Avoid putting sensitive data in the path. Prefer short-lived tokens and exchange them server-side.

URL PatternIn-app destinationNotes
/p/:idProductDetailsScreen(id)Fetch product by id
/invite/:codeInviteAcceptScreen(code)Often requires auth
/reset-passwordResetPasswordScreen(token)Token in query, validate quickly
/u/:usernameProfileScreen(username)Consider slug collisions

💡 Tip: Keep URLs stable. Renaming /p/:id to /product/:id later 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_links for Universal Links and App Links
  • go_router for declarative routing and redirection
  • optional analytics SDK (Firebase Analytics, Segment, Amplitude) for attribution events

Add packages#

Bash
flutter pub add go_router app_links

This service normalizes incoming URLs and forwards them to go_router. Keep it small and testable.

Dart
// 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.

Dart
// 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;
    },
  );
}

There are two cases: app opened via link, and app already running.

Dart
// 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 isLoggedIn asynchronously, build the router with a synchronous state holder and trigger refresh when auth finishes, otherwise you’ll get redirect loops or incorrect screens.

Universal Links require two things:

  1. 1
    Enable Associated Domains in Xcode.
  2. 2
    Host 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.com
    • applinks: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-association
  • https://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:

JSON
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.example.app",
        "paths": ["/p/*", "/invite/*", "/reset-password*"]
      }
    ]
  }
}

Where:

  • TEAMID is your Apple Team ID
  • com.example.app is 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.

Android App Links require:

  1. 1
    Intent filters in your AndroidManifest.xml.
  2. 2
    A Digital Asset Links file on your domain.
  3. 3
    Correct 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:

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="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

Place it at:

  • https://example.com/.well-known/assetlinks.json

Example:

JSON
[
  {
    "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:

Bash
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.

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#

OptionReliabilitySetup complexityNotes
Your own “install then claim” flowMediumMediumRequires backend + clipboard or user login handshake
Third-party attribution providerHighMedium to highAppsFlyer, Adjust, Branch commonly used
Firebase Dynamic LinksDeprecated for new projectsLow to mediumAvoid 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#

  1. 1
    Link opens a landing page like /invite/ABCDEF.
  2. 2
    The landing page stores the code server-side associated with an anonymous session ID.
  3. 3
    The landing page offers “Open app” and “Install app”.
  4. 4
    After 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#

FieldExampleWhy it matters
Full URLhttps://example.com/p/123?utm_source=...Debugging and attribution
Path/p/123Routing and content grouping
Query paramsutm_source, utm_campaign, refMarketing performance
TimestampISO stringSession stitching
Install stateinstalled or notDeep link vs deferred

Example: extract and log UTM parameters#

Dart
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_opened
  • deep_link_routed
  • invite_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.

iOS checks

CheckExpectedFix
AASA reachable200 OK, no redirectRemove redirects, serve from root or .well-known
appID matchesTEAMID and bundle ID correctUpdate AASA details
Associated Domainsapplinks:example.com addedEnable capability in Xcode
CachingiOS caches verificationDelete app, reboot device, reinstall

Android checks

CheckExpectedFix
assetlinks path/.well-known/assetlinks.jsonPlace file correctly
Fingerprintmatches installed app signatureUse correct debug or release SHA-256
Manifestintent-filter includes host and schemeAdd filter with autoVerify
Verification statusverified in systemUse pm get-app-links to inspect

Common causes:

  1. 1
    You route using uri.toString() but your router expects only the path.
  2. 2
    You are using go when you needed push, or vice versa.
  3. 3
    Auth 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.

Dart
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.go repeatedly 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 go for “this link defines the current location” and push for “open on top of current flow”

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.

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

Share
A
Adrijan OmićevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.