← Articles

Handling deep links in Flutter

By Ann Tech · 1 May 2025

Deep links open specific screens in your Flutter app from external URLs. They arrive from push notifications, QR codes, emails, other apps, and web browsers. Handling them correctly requires both native platform configuration and Flutter navigation logic.

Custom schemes (myapp://orders/order-1): any app can register a custom scheme, so they're vulnerable to interception. Fine for internal use, not for security-sensitive flows.

Universal Links (iOS) / App Links (Android): HTTPS links that are verified to belong to your domain. Cannot be intercepted by other apps. Preferred for anything sensitive.

Deferred deep links: the user doesn't have the app installed. They're sent to the App Store/Play Store, install the app, then are taken to the intended screen on first launch.

dependencies:
  app_links: ^6.0.0  # Cross-platform deep link handling
class DeepLinkService {
  final _appLinks = AppLinks();
  StreamSubscription? _sub;

  Future<void> init(GoRouter router) async {
    // Handle link that launched the app (when app was terminated)
    final initialLink = await _appLinks.getInitialLink();
    if (initialLink != null) {
      _navigate(router, initialLink);
    }

    // Handle links while app is running
    _sub = _appLinks.uriLinkStream.listen(
      (uri) => _navigate(router, uri),
      onError: (e) => debugPrint('Deep link error: $e'),
    );
  }

  void _navigate(GoRouter router, Uri uri) {
    // Convert the incoming URI to a go_router path
    final path = uri.path;
    final query = uri.queryParameters;

    if (path.startsWith('/orders/')) {
      router.go(path);
    } else if (path == '/reset-password') {
      router.go('/auth/reset-password?token=${query['token']}');
    } else {
      router.go('/');
    }
  }

  void dispose() => _sub?.cancel();
}

go_router handles URI parsing for you:

final router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(path: '/', builder: (_, __) => const HomeScreen()),
    GoRoute(
      path: '/orders/:id',
      builder: (_, state) => OrderDetailScreen(
        orderId: state.pathParameters['id']!,
      ),
    ),
    GoRoute(
      path: '/auth/reset-password',
      builder: (_, state) => ResetPasswordScreen(
        token: state.uri.queryParameters['token'],
      ),
    ),
  ],
  // Redirect unauthenticated deep links to login
  redirect: (context, state) {
    final isSignedIn = ref.read(isSignedInProvider);
    if (!isSignedIn && !state.uri.path.startsWith('/auth')) {
      final destination = Uri.encodeComponent(state.uri.toString());
      return '/auth/login?redirect=$destination';
    }
    return null;
  },
);

android/app/src/main/AndroidManifest.xml:

<activity ...>
  <!-- App Links -->
  <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="myapp.example.com" />
  </intent-filter>

  <!-- Custom scheme fallback -->
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="myapp" />
  </intent-filter>
</activity>

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

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.myapp",
    "sha256_cert_fingerprints": ["AA:BB:CC:DD:..."]
  }
}]

Get fingerprint: keytool -list -v -keystore your.keystore -alias your-alias

ios/Runner/Runner.entitlements:

<key>com.apple.developer.associated-domains</key>
<array>
  <string>applinks:myapp.example.com</string>
</array>

https://myapp.example.com/.well-known/apple-app-site-association:

{
  "applinks": {
    "details": [{
      "appIDs": ["TEAMID.com.example.myapp"],
      "components": [
        {"/#": ".*/orders/.*"},
        {"/#": ".*/products/.*"},
        {"/": "/share/*"}
      ]
    }]
  }
}

Firebase Dynamic Links is deprecated (shutting down August 2025). Migrate to:

  • Branch.io for deferred deep links and analytics
  • AppsFlyer for attribution + deep links
  • Or implement custom logic with App Links/Universal Links
// firebase_messaging
FirebaseMessaging.onMessageOpenedApp.listen((message) {
  final link = message.data['deep_link'] as String?;
  if (link != null) {
    final uri = Uri.parse(link);
    router.go(uri.path, extra: uri.queryParameters);
  }
});

Testing

# Android
adb shell am start -a android.intent.action.VIEW \
  -d 'https://myapp.example.com/orders/order-123'

# iOS simulator
xcrun simctl openurl booted 'https://myapp.example.com/orders/order-123'

# Custom scheme
adb shell am start -a android.intent.action.VIEW -d 'myapp://orders/order-123'
xcrun simctl openurl booted 'myapp://orders/order-123'

Common pitfalls

Missing authentication guard. A deep link to /orders/order-1 should redirect to login if the user isn't authenticated, then return to the intended screen after login. Always add a redirect guard in go_router.

Not handling the initial link when the app is terminated. uriLinkStream only fires for links received while the app is running. getInitialLink() handles the case where the app was launched via a link from a terminated state.

Hosting the AASA/assetlinks file on a redirecting URL. Both Apple and Google require these files to be served without redirects. If https://example.com/.well-known/apple-app-site-association redirects to https://www.example.com/..., verification fails.

Sign in to like, dislike, or report.

Handling deep links in Flutter — ANN Tech