Deep Linking in Flutter Keeps Breaking — Here's How to Fix the 5 Most Common Failures

Deep linking is the feature that clients always ask for and developers always dread. "Just make it so when someone clicks a link, it opens the right screen in the app." Simple, right?

Then you start implementing it and discover that deep links work in debug mode but not in release builds. They open the app but land on the home screen instead of the target page. They work on Android but not iOS. They work on iOS but open a new app instance instead of navigating within the running one.

I've shipped deep linking in four production Flutter apps. Every single time, I hit at least three of the problems below. Here's the fix for each one.

This is the most common failure, and it's almost always a routing configuration issue.

Why It Happens

Your app receives the deep link, but your router doesn't know how to map it to a screen. The link URI comes in as https://yourapp.com/products/123, but your route table is looking for /products/:id without the host prefix.

The Fix: Parse the URI Correctly

If you're using GoRouter (which you should be — it has first-class deep linking support):

final router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/products/:id',
      builder: (context, state) {
        final productId = state.pathParameters['id']!;
        return ProductDetailScreen(productId: productId);
      },
    ),
    GoRoute(
      path: '/orders/:orderId',
      builder: (context, state) {
        final orderId = state.pathParameters['orderId']!;
        return OrderDetailScreen(orderId: orderId);
      },
    ),
  ],
);

GoRouter handles the URI-to-path mapping automatically. But if you're using Navigator 2.0 directly or a custom solution, make sure you're stripping the scheme and host:

// ❌ This won't match
final uri = Uri.parse('https://yourapp.com/products/123');
// uri.toString() == 'https://yourapp.com/products/123'
 
// ✅ Use only the path
final path = uri.path; // '/products/123'

Common Missing Step: Set initialLocation

final router = GoRouter(
  initialLocation: '/',
  // If the app is opened via deep link, GoRouter uses the deep link
  // as the initial location automatically. But you need a fallback.
  routes: [...],
  errorBuilder: (context, state) => NotFoundScreen(),
);

Without errorBuilder, an unmatched deep link shows a blank screen or crashes.

Why It Happens

Android and iOS use completely different deep linking systems:

  • Android: App Links (HTTP) + Intent Filters (custom schemes)
  • iOS: Universal Links (HTTP) + Custom URL Schemes

And the iOS setup has a critical extra step that most tutorials mention but don't explain well: the Apple App Site Association (AASA) file.

The Fix for iOS

Step 1: Add Associated Domains in Xcode

Open ios/Runner.xcworkspace in Xcode. Go to Runner → Signing & Capabilities → + Capability → Associated Domains. Add:

applinks:yourapp.com

Note: no https:// prefix. Just the domain.

Step 2: Host the AASA File

Your server must serve a JSON file at https://yourapp.com/.well-known/apple-app-site-association (no file extension, with Content-Type: application/json):

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAM_ID.com.yourcompany.yourapp",
        "paths": ["/products/*", "/orders/*", "/invite/*"]
      }
    ]
  }
}

The appID is your Apple Team ID + your bundle identifier. Get your Team ID from the Apple Developer portal.

Step 3: Validate It

Apple caches the AASA file. After deploying, use Apple's validator:

https://app-site-association.cdn-apple.com/a/v1/yourapp.com

If this returns your AASA file, iOS will recognize your links. If not, check:

  • The file is served over HTTPS (not HTTP)
  • There's no redirect (301/302 breaks it)
  • Content-Type is application/json
  • The file is at the root domain, not a subdirectory

Why It Happens

On Android, a deep link can launch a new Activity instead of routing within the existing one. This happens when your AndroidManifest.xml has the wrong launchMode.

The Fix

In android/app/src/main/AndroidManifest.xml, make sure your main activity uses singleTask:

<activity
    android:name=".MainActivity"
    android:launchMode="singleTask"
    android:exported="true">
    
    <!-- Deep link intent filter -->
    <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="/products" />
    </intent-filter>
</activity>

singleTask ensures that if the app is already running, the deep link is delivered to the existing instance instead of creating a new one.

On iOS, this is handled automatically by SceneDelegate — but make sure you're handling the link in application(_:continue:restorationHandler:) and not in application(_:open:options:) (which is for custom URL schemes, not Universal Links).

Why It Happens

Query parameters (?ref=email&campaign=winter) get stripped somewhere in the pipeline. This usually happens when your router extracts path parameters but ignores query parameters.

The Fix

GoRouter gives you query parameters via state.uri.queryParameters:

GoRoute(
  path: '/products/:id',
  builder: (context, state) {
    final productId = state.pathParameters['id']!;
    final referrer = state.uri.queryParameters['ref'];
    final campaign = state.uri.queryParameters['campaign'];
 
    // Track attribution
    if (referrer != null) {
      analytics.trackDeepLink(referrer: referrer, campaign: campaign);
    }
 
    return ProductDetailScreen(productId: productId);
  },
),

For deep links like https://yourapp.com/products/123?ref=email&campaign=winter:

  • state.pathParameters['id']'123'
  • state.uri.queryParameters['ref']'email'
  • state.uri.queryParameters['campaign']'winter'

Common Bug: Encoding Issues

If your parameters contain special characters, make sure they're URL-encoded:

// ❌ Breaks if productName has spaces or special chars
'https://yourapp.com/search?q=blue shoes'
 
// ✅ Properly encoded
'https://yourapp.com/search?q=${Uri.encodeComponent('blue shoes')}'

Why It Happens

There are two ways a deep link reaches your app:

  1. Cold start — App is not running. The OS launches it with the link as the initial route.
  2. Resume — App is in the background. The OS delivers the link to the running instance.

Most implementations only handle case 2. They listen for incoming links via a stream but forget to check the initial link on cold start.

The Fix

// Handle BOTH cold start and resume links
class DeepLinkHandler extends StatefulWidget {
  final Widget child;
  const DeepLinkHandler({required this.child, super.key});
 
  @override
  State<DeepLinkHandler> createState() => _DeepLinkHandlerState();
}
 
class _DeepLinkHandlerState extends State<DeepLinkHandler> {
  late AppLinks _appLinks;
  StreamSubscription? _subscription;
 
  @override
  void initState() {
    super.initState();
    _appLinks = AppLinks();
    _initDeepLinks();
  }
 
  Future<void> _initDeepLinks() async {
    // 1. Handle cold start — app was launched via deep link
    final initialLink = await _appLinks.getInitialLink();
    if (initialLink != null) {
      _handleLink(initialLink);
    }
 
    // 2. Handle resume — app receives link while running
    _subscription = _appLinks.uriLinkStream.listen((uri) {
      _handleLink(uri);
    });
  }
 
  void _handleLink(Uri uri) {
    // Navigate using your router
    GoRouter.of(context).go(uri.path);
  }
 
  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }
 
  @override
  Widget build(BuildContext context) => widget.child;
}

If you're using GoRouter, it handles this automatically — but only if your GoRouter instance is created at the top of your widget tree and uses MaterialApp.router.

Don't wait until production to find issues. Test during development:

Android

# Test from terminal
adb shell am start -a android.intent.action.VIEW \
  -d "https://yourapp.com/products/123" \
  com.yourcompany.yourapp

iOS

# Test from terminal (simulator)
xcrun simctl openurl booted "https://yourapp.com/products/123"

Automated Test

testWidgets('deep link navigates to product detail', (tester) async {
  final router = GoRouter(
    initialLocation: '/products/456',
    routes: [...],
  );
 
  await tester.pumpWidget(
    MaterialApp.router(routerConfig: router),
  );
  await tester.pumpAndSettle();
 
  expect(find.byType(ProductDetailScreen), findsOneWidget);
});

Key Takeaways

  • Use GoRouter. It handles deep linking out of the box better than any custom solution.
  • Set up both Android (intent-filter) and iOS (Associated Domains + AASA file`). They're completely different systems.
  • Use singleTask launch mode on Android to prevent new instances.
  • Handle both cold start and resume links — most bugs come from missing the cold start case.
  • Always test with adb shell and xcrun simctl before shipping.
  • Add an error/fallback screen for unmatched deep links — don't let users see a blank page.

Deep linking is one of those features that feels like it should be simple but has a dozen platform-specific gotchas. Get it right once, document your setup, and you'll breeze through it on every future project.

Find me at: itszain.dev

Related Articles

How to Show Real File Upload Progress in Flutter (Not Fake Progress Bars)

Most Flutter upload tutorials fake the progress bar. Here's how to track actual byte-level upload progress using Dio's multipart requests and stream transformers — with a production-ready implementation.

5 min read

Stop Overengineering State Management in Flutter (When setState Is Enough)

Not every Flutter screen needs Riverpod, BLoC, or Redux. Here's a practical framework for choosing the right state management approach based on actual complexity — with real examples of when setState, ValueNotifier, and providers each make sense.

7 min read

Building Offline Mode in Flutter That Actually Works (Sync Queue Pattern)

Most Flutter offline tutorials only cache GET requests. Here's how to build a real offline mode that queues mutations, syncs when connectivity returns, and handles conflicts — with a production-tested sync queue architecture.

8 min read