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.
Problem 1: Links Open the App but Land on the Home Screen
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.
Problem 2: Deep Links Work on Android But Not iOS
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
Problem 3: Deep Link Opens a New App Instance Instead of Navigating
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).
Problem 4: Deep Link Parameters Get Lost
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')}'Problem 5: Deep Links Don't Work When the App Is Killed
Why It Happens
There are two ways a deep link reaches your app:
- Cold start — App is not running. The OS launches it with the link as the initial route.
- 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.
Testing Deep Links
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.yourappiOS
# 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
singleTasklaunch 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 shellandxcrun simctlbefore 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