Flutter Deep Linking with Universal Links and App Links: A Complete Guide

A practical, end-to-end guide to deep linking in Flutter using iOS Universal Links and Android App Links, with setup, code, testing, and troubleshooting.

ASOasis
7 min read
Flutter Deep Linking with Universal Links and App Links: A Complete Guide

Image used for representation purposes only.

Overview

Deep linking lets a URL open a specific screen inside your Flutter app. On iOS this is powered by Universal Links; on Android it’s App Links. Done well, users tap an https:// link anywhere—email, web, social—and land on the exact in‑app destination, with a safe fallback to your website if the app isn’t installed.

This guide walks you through:

  • Designing a robust link strategy
  • Configuring iOS Universal Links and Android App Links
  • Wiring links into Flutter navigation (with go_router and popular plugins)
  • Handling cold/warm starts, auth gates, analytics
  • Testing and troubleshooting like a pro

Use real, canonical HTTPS URLs that your website also serves (e.g., https://example.com/product/42) . Benefits:

  • One link works across web, iOS, and Android
  • SEO and sharing work out of the box
  • Safer than custom URI schemes (no spoofing by other apps)

Custom schemes (myapp://product/42) can supplement for legacy or partner integrations, but prefer HTTPS universal/app links for primary flows.

Design tips:

  • Keep paths stable: /product/:id, /user/:id, /cart
  • Use query params for filters and campaign tags (e.g., ?ref=summer)
  • Avoid relying on URL fragments (#hash); use path and query instead

Universal Links route Safari/Notes/Mail taps directly into your app if you “prove” domain ownership via an association file.

  1. Host apple-app-site-association (AASA)

Example AASA permitting /product/* and /promo/* for a single app:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "ABCDE12345.com.example.app",
        "paths": [
          "/product/*",
          "/promo/*"
        ]
      }
    ]
  }
}

Notes:

  • appID = Apple Team ID + “.” + bundle identifier
  • Use additional entries for other bundle IDs (e.g., beta/build flavors)
  • To cover subdomains, you can add separate AASA files on each subdomain and entitlements like applinks:*.example.com
  1. Add Associated Domains entitlement
  • In Xcode: Runner target > Signing & Capabilities > + Capability > Associated Domains
  • Add entries like:
applinks:example.com
applinks:www.example.com
  1. Build and test on a real device
  • Universal Links require a real iOS device for full verification
  • After install, tapping https://example.com/product/42 in Mail/Notes/Safari should open your app directly (if paths match); otherwise Safari opens the web page

Debugging tips:

  • Verify AASA with curl (must not 301/302):
curl -I https://example.com/apple-app-site-association
curl https://example.com/apple-app-site-association
  • In Simulator: open a link via:
xcrun simctl openurl booted "https://example.com/product/42"

Android App Links use Digital Asset Links to verify your domain.

  1. Add intent filter to AndroidManifest.xml (android/app/src/main/AndroidManifest.xml)
<activity android:name="io.flutter.embedding.android.FlutterActivity" ...>
  <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="/" />
    <!-- Optional: also accept www subdomain -->
    <data android:scheme="https" android:host="www.example.com" android:pathPrefix="/" />
  </intent-filter>
</activity>
  1. Host assetlinks.json

Example:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": [
        "11:22:33:AA:BB:...:FF"  
      ]
    }
  }
]

Notes:

  • Include fingerprints for each signing cert you ship with (debug, release, upload)
  • You can list multiple targets for variants (e.g., com.example.app.staging)
  1. Test and verify
  • Check file:
curl https://example.com/.well-known/assetlinks.json
  • Trigger a link:
adb shell am start -a android.intent.action.VIEW -d "https://example.com/product/42"
  • Inspect verification state (Android 12+):
adb shell pm get-app-links com.example.app

If your domain isn’t verified, users may see a chooser.

You’ll handle two cases:

  • Cold start (app not running): open the right screen immediately
  • Warm start (app in foreground/background): route without rebuilding the app

Two popular approaches: app_links or uni_links. Below uses app_links and go_router.

Dependencies

# pubspec.yaml
dependencies:
  go_router: ^14.0.0
  app_links: ^6.0.0

Define routes (go_router)

final _router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
      routes: [
        GoRoute(
          path: 'product/:id',
          builder: (context, state) => ProductScreen(id: state.pathParameters['id']!),
        ),
        GoRoute(
          path: 'promo/:code',
          builder: (context, state) => PromoScreen(code: state.pathParameters['code']!),
        ),
      ],
    ),
    // Auth-gated example
    GoRoute(
      path: '/profile',
      redirect: (context, state) {
        final authed = context.read<Auth>().isSignedIn;
        return authed ? null : '/signin?from=${Uri.encodeComponent(state.uri.toString())}';
      },
      builder: (context, state) => const ProfileScreen(),
    ),
  ],
);
class DeepLinkHandler with WidgetsBindingObserver {
  final AppLinks _appLinks = AppLinks();
  StreamSubscription<Uri>? _sub;

  Future<void> init(BuildContext context) async {
    // 1) Cold start
    final Uri? initial = await _appLinks.getInitialAppLink();
    if (initial != null) _route(context, initial);

    // 2) Warm start / foreground
    _sub = _appLinks.uriLinkStream.listen(
      (uri) => _route(context, uri),
      onError: (err) => debugPrint('Deep link error: $err'),
    );

    WidgetsBinding.instance.addObserver(this);
  }

  void _route(BuildContext context, Uri uri) {
    // Normalize: keep path + query
    final path = uri.path.isEmpty ? '/' : uri.path;
    final query = uri.hasQuery ? '?${uri.query}' : '';
    context.go('$path$query');
  }

  void dispose() {
    _sub?.cancel();
    WidgetsBinding.instance.removeObserver(this);
  }
}

Wire into your app

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final _router = _router; // from earlier
  final _dl = DeepLinkHandler();

  @override
  void initState() {
    super.initState();
    // Delay until context exists
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _dl.init(navigatorKey.currentContext!);
    });
  }

  @override
  void dispose() {
    _dl.dispose();
    super.dispose();
  }

  static final navigatorKey = GlobalKey<NavigatorState>();

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: _router,
      debugShowCheckedModeBanner: false,
    );
  }
}

If you prefer uni_links, the pattern is similar:

final initialUri = await getInitialUri();
_linkSub = uriLinkStream.listen((uri) => _route(context, uri));

Make sure you add the same platform configuration; plugins just deliver the URI.

Cold vs warm start nuances

  • Cold start: Parse the initial URI before showing your default screen. If you must do async setup (e.g., fetch user/session), show a lightweight splash and navigate once ready.
  • Warm start: Ensure uriLinkStream pushes do not create duplicate pages. Prefer go_router’s context.go to replace instead of push for canonical deep-link routes.
  • Background resume: Handle cases where the app was on a different tab; decide whether deep link should override current flow (e.g., continue checkout vs open promo).

Auth-gated routes

If a deep link targets a protected screen, either:

  • Bounce to sign-in and then redirect back using a from query parameter, or
  • Show a read-only preview on web, but require auth in-app

In go_router, use redirect as shown earlier to centralize this logic.

Analytics and attribution

  • Capture the full URI (path + query) on arrival and log an event (Firebase Analytics, Segment, etc.)
  • Preserve campaign params (utm_source, etc.). If you redirect to sign-in, carry them through to the post-login destination.
  • Firebase Dynamic Links, Branch, or AppsFlyer can create deferred links—if the app isn’t installed, user goes to the store; after first open, SDK delivers the original target URI.
  • In Flutter, read the “pending” deep link on first launch via each SDK’s API before normal routing.

Testing checklist

  • iOS
    • AASA reachable at the correct path, correct content-type, no redirects
    • Entitlements include all domains you expect (applinks:example.com)
    • Real device test from Mail/Notes/Safari
    • Simulator quick test via simctl
  • Android
    • assetlinks.json reachable under .well-known with correct fingerprints
    • Manifest intent-filter has autoVerify and https data elements
    • adb am start with sample URLs
    • pm get-app-links shows “verified” after install
  • Flutter
    • Cold start: kill app, tap link, lands on the right screen
    • Warm start: app in background/foreground, tap link, routes once
    • Edge cases: unknown path -> fallback (e.g., 404 screen)

Troubleshooting guide

  • iOS opens Safari instead of app
    • AASA missing/redirecting/invalid JSON
    • Bundle ID or Team ID mismatch in appID
    • Path not listed in paths/components
    • Not testing on a real device
  • Android shows app chooser or opens browser
    • Domain not verified: fingerprint in assetlinks.json doesn’t match signing cert
    • Wrong package_name or typo in .well-known path
    • Multiple apps claim the same domain and verification failed
  • Flutter doesn’t route
    • Plugin not initialized early enough; ensure getInitialAppLink is awaited before rendering the final shell
    • Using push instead of go, causing back stack duplication
    • Route patterns don’t match (e.g., missing leading slash)

Security best practices

  • Serve AASA and assetlinks.json over HTTPS with valid certificates
  • Restrict paths to only what you intend the app to claim (avoid “/*” unless necessary)
  • Validate and sanitize incoming URIs before acting (e.g., deep link to payment screen)
  • Keep signing keys safe; rotate and update fingerprints when needed

Quick setup checklist

  • Decide URL structure (HTTPS canonical URLs)
  • Host AASA on iOS domain(s)
  • Add Associated Domains entitlements
  • Add Android intent-filter with autoVerify
  • Host .well-known/assetlinks.json with correct fingerprints
  • Implement Flutter listener (app_links or uni_links)
  • Map URIs to routes (go_router)
  • Test cold/warm starts on both platforms
  • Add analytics and auth redirects

Conclusion

Universal Links and App Links make your Flutter app feel native to the web: one URL, everywhere. With domain verification in place, a thin Flutter listener, and clean route mapping, your users get a seamless handoff from browser to app—reliably, securely, and fast. Keep your AASA/assetlinks tight, test with real devices, and treat deep links as first‑class navigation.

Related Posts