Opening Hook
Deep links in Flutter: a practical guide for developers means the difference between users landing on the right screen and your app crashing on launch. Whether a user taps a notification, follows a link from email, or shares a URL with a friend, you need to route them to the exact place they expect without losing context or state. This guide walks through the complete setup—from platform configuration through testing—so your deep linking implementation handles both cold and warm app starts reliably.
What Are Deep Links and Why They Matter
Deep links point directly to content inside your app rather than the app's home screen. A notification might link to myapp://order/12345. An SMS might contain https://myapp.com/promo/summer-sale. Without proper handling, users see your home screen, confused and frustrated. With deep linking configured correctly, they land on the order detail screen or promotion page immediately.
Deep links come in two flavors: custom schemes like myapp:// and universal/app links using https://. Universal links are more secure and user-friendly because they look like regular web URLs. Most production apps use both to cover different scenarios.
Configure Android App Links and iOS Universal Links
Android App Links Setup
Android App Links require you to prove domain ownership. Start by creating an assetlinks.json file and hosting it at https://yourdomain.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:..."]
}
}
]Find your app's SHA-256 fingerprint using:
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass androidNext, update AndroidManifest.xml to declare the intent filter:
<activity
android:name=".MainActivity"
android:exported="true">
<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.com" />
</intent-filter>
</activity>The android:autoVerify="true" attribute tells Android to verify the domain. Without it, the OS won't treat the links as direct app links—users will see a chooser dialog instead.
iOS Universal Links Setup
iOS requires an apple-app-site-association (AASA) file at https://yourdomain.com/.well-known/apple-app-site-association. Note: no file extension.
{
"applinks": {
"apps": [],
"details": [
{
"appID": "ABCD123456.com.example.myapp",
"paths": ["/order/*", "/promo/*"]
}
]
}
}Find your Team ID and Bundle ID in Xcode, then combine them as TeamID.BundleID.
In Xcode, enable Associated Domains under your target's Signing & Capabilities:
applinks:myapp.comiOS will fetch and cache this file when your app first launches. Test by removing your app, clearing Safari data, then tapping a link in Safari. If the AASA file is malformed or unreachable, iOS silently falls back to web view behavior.
go_router Handles Incoming Paths
Use go_router to parse incoming deep links and navigate to the correct screen. It abstracts away platform-specific link handling and gives you declarative routing.
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/order/:orderId',
builder: (context, state) {
final orderId = state.pathParameters['orderId']!;
return OrderDetailScreen(orderId: orderId);
},
),
GoRoute(
path: '/promo/:promoCode',
builder: (context, state) {
final code = state.queryParameters['code'];
return PromoScreen(code: code);
},
),
],
);Pass the router to your MaterialApp:
MaterialApp.router(
routerConfig: router,
title: 'My App',
)When a user taps https://myapp.com/order/12345, go_router matches it against your routes and builds the appropriate screen. If no route matches, you can define a fallback with GoRoute(path: '/:rest').
For custom schemes like myapp://order/12345, go_router still works because the operating system passes the URL to your app's `onGenerateRoute` callback. Configure go_router's initialRoute to handle the incoming link:
final router = GoRouter(
initialRoute: _getInitialRoute(),
routes: [...],
);You'll also need to handle the initial link at app startup and subsequent links while the app is running. Use uni_links or app_links plugins to listen for incoming links:
StreamSubscription? _deepLinkSubscription;
@override
void initState() {
super.initState();
_deepLinkSubscription =
AppLinks().uriLinkStream.listen((uri) {
router.go(uri.toString());
});
}
@override
void dispose() {
_deepLinkSubscription?.cancel();
super.dispose();
}Validate Parameters Before Loading Data
Never assume the incoming parameter is valid. A user might craft a malicious URL or follow an outdated link to a deleted order. Validate first, then load data.
GoRoute(
path: '/order/:orderId',
builder: (context, state) {
final orderId = state.pathParameters['orderId']!;
// Validate format
if (!RegExp(r'^\d+$').hasMatch(orderId)) {
return const InvalidLinkScreen();
}
// Parse safely
final id = int.tryParse(orderId);
if (id == null) {
return const InvalidLinkScreen();
}
return OrderDetailScreen(orderId: id);
},
);Inside your screen, load data and handle errors gracefully:
class OrderDetailScreen extends StatelessWidget {
final int orderId;
const OrderDetailScreen({required this.orderId});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _fetchOrder(orderId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorScreen(
message: 'Order not found. Please check the link.',
);
}
final order = snapshot.data as Order;
return OrderContent(order: order);
},
);
}
Future<Order> _fetchOrder(int id) async {
final response = await http.get(Uri.parse('/api/orders/$id'));
if (response.statusCode == 404) {
throw Exception('Order not found');
}
return Order.fromJson(jsonDecode(response.body));
}
}This approach prevents crashes and gives users clear feedback when a link is stale or malformed.
Test Cold Starts and Warm Starts
Deep links behave differently depending on whether your app is already running. Test both scenarios thoroughly.
Cold Start Testing
A cold start happens when your app isn't running. The OS launches it fresh and passes the deep link.
Android: Use adb to simulate a deep link:
adb shell am start -a android.intent.action.VIEW \
-d "https://myapp.com/order/12345" com.example.myappKill your app completely, then run this command. Your app should launch and navigate directly to the order detail screen.
iOS: Use Xcode's scheme editor. Go to Product → Scheme → Edit Scheme, select Run, and set the URL in "Arguments Passed On Launch":
myapp://order/12345Or use xcrun from terminal:
xcrun simctl openurl booted "https://myapp.com/order/12345"The app should launch and land on the correct screen.
Warm Start Testing
A warm start occurs when your app is already running in the background. The OS sends the link without relaunching the app, and your app should update the UI in place.
Launch your app normally, navigate to the home screen, then send a deep link from another app or terminal. Your app should jump to the new screen without reloading everything.
Use the same adb and xcrun commands above, but don't kill your app first. Your app's link stream listener should receive the URL and call router.go().
Test State Preservation
Some deep links might preserve navigation state. For example, a promo link might land on a promo screen, but users should still be able to tap back and return to where they were. Test that your back button behavior is intuitive.
GoRoute(
path: '/promo/:code',
builder: (context, state) {
return PromoScreen(code: state.pathParameters['code']!);
},
pageBuilder: (context, state) {
// Replace the entire history or preserve it?
return CustomTransitionPage(
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return child; // Slide in transition
},
child: PromoScreen(code: state.pathParameters['code']!),
);
},
);Common Mistakes
- Forgetting domain verification: Without
assetlinks.jsonorapple-app-site-association, your app links won't work. Test from the Settings app under App Links (Android) or open links in Safari (iOS) to verify. - Not handling null parameters: Always check if a path parameter exists and parse it safely. Assume every deep link is potentially malicious or outdated.
- Testing only in warm starts: Your app might work perfectly when already running but crash on cold start because initialization logic isn't set up. Always test both.
- Ignoring query parameters: Don't forget about URL query strings. A link like
https://myapp.com/order/12345?ref=email&utm_source=campaigncarries extra context that you might want to log or display. - Hardcoding routes in multiple places: Define routes in one location (your
GoRouterconfiguration) and reference them elsewhere. Hard-coding paths leads to mismatches and broken links.
Key Takeaways
- Deep links require platform-specific configuration:
assetlinks.jsonfor Android andapple-app-site-associationfor iOS. Without them, universal links won't work reliably. go_routersimplifies deep link routing by parsing URLs declaratively and routing to the correct screen.- Always validate and parse incoming parameters before using them. Assume links might be stale, malformed, or crafted by users.
- Test cold starts (app not running) and warm starts (app in background) separately. They behave differently and both are essential to your user experience.
- Use query parameters to pass metadata like tracking codes or feature flags alongside your main route path.






Comments
Loading comments…