A user taps a link in an email: https://yourapp.com/product/42. What happens? If deep linking is set
up correctly, your Flutter app opens directly to that product page. If it's not, the user lands on your website —
or worse, a blank screen and back to the home page. Deep linking is the difference between a seamless user
experience and a frustrating one.
I've set up deep linking on over a dozen Flutter projects. The routing part is straightforward —
GoRouter handles that
in a few lines. The tricky part is the platform configuration: verification
files, entitlements, intent filters, and the dozen small things that break silently. This guide covers every step,
from creating your verification files to handling edge cases like authenticated routes and deferred deep links.
What You'll Learn
- The difference between URI schemes, Universal Links, and App Links
- Android App Links:
AndroidManifest.xmlintent filters andassetlinks.json - iOS Universal Links: entitlements, associated domains, and
apple-app-site-association - GoRouter setup for deep link handling with path parameters and redirects
- Authentication guards that work with deep links
- Deferred deep linking for first-time installs
- Testing deep links on emulators, simulators, and real devices
1. How Deep Links Work (Three Types)
Before writing code, you need to understand the three types of deep links and when to use each:
URI Scheme Links (Custom Scheme)
Format: myapp://product/123
These use a custom protocol. Any app can register any scheme — there's no ownership verification. If two apps
register myapp://, the OS shows a chooser dialog on Android or picks one unpredictably on iOS. Use
these only during development or for app-to-app communication you control.
App Links (Android)
Format: https://yourapp.com/product/123
These are standard HTTPS URLs verified through a file hosted on your domain (assetlinks.json).
Because the domain proves ownership, Android opens your app directly — no chooser dialog. If the app isn't
installed, the link opens in the browser.
Universal Links (iOS)
Format: https://yourapp.com/product/123
Same concept as App Links, but Apple's implementation. Verified through apple-app-site-association
hosted on your domain. iOS opens your app directly when it's installed. If not, Safari opens the URL.
| Feature | URI Scheme | App Links (Android) | Universal Links (iOS) |
|---|---|---|---|
| Format | myapp://path |
https://domain/path |
https://domain/path |
| Ownership verified | No | Yes (assetlinks.json) | Yes (AASA file) |
| Requires server | No | Yes | Yes |
| Fallback without app | Error | Opens in browser | Opens in Safari |
| Production recommended | No | Yes | Yes |
2. Setting Up Android App Links
Step 1: Add Intent Filters to AndroidManifest.xml
Open android/app/src/main/AndroidManifest.xml and add intent filters inside the
<activity> tag:
<activity
android:name=".MainActivity"
android:launchMode="singleTop">
<!-- 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="/product" />
</intent-filter>
<!-- Handle all paths under your domain -->
<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" />
</intent-filter>
</activity>
The android:autoVerify="true" attribute tells Android to verify your domain ownership at install
time.
Step 2: Host the assetlinks.json File
Create a file at https://yourapp.com/.well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.yourapp",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
]
}
}
]
Get your SHA-256 fingerprint with:
# Debug key
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android
# Release key
keytool -list -v -keystore your-release-key.jks -alias your-alias
Include BOTH debug and release fingerprints in the file during development. Remove the debug fingerprint before production release.
3. Setting Up iOS Universal Links
Step 1: Add Associated Domains Entitlement
Open ios/Runner/Runner.entitlements (create it if it doesn't exist) and add:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:yourapp.com</string>
<string>applinks:yourapp.com?mode=developer</string>
</array>
</dict>
</plist>
The ?mode=developer variant lets you test without Apple caching your AASA file. Remove it for
production.
Step 2: Enable Associated Domains in Xcode
Open ios/Runner.xcworkspace in Xcode. Go to Runner target → Signing & Capabilities → +
Capability → Associated Domains. Add applinks:yourapp.com.
Step 3: Host the apple-app-site-association File
Create a file at https://yourapp.com/.well-known/apple-app-site-association (no file extension):
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": ["TEAMID.com.yourcompany.yourapp"],
"components": [
{ "/": "/product/*", "comment": "Product pages" },
{ "/": "/user/*", "comment": "User profiles" },
{ "/": "/settings", "comment": "Settings page" }
]
}
]
}
}
Replace TEAMID with your Apple Developer Team ID. You can find it in the Apple Developer portal
under Membership.
Apple Caches This File
Apple's CDN caches your AASA file for up to 24 hours. If you update it, changes won't take effect immediately.
During development, use the ?mode=developer associated domain entry to bypass caching. On device,
go to Settings → Developer → Universal Links → Diagnostics to force a refresh.
4. GoRouter Configuration for Deep Links
With the platform side configured, handle routing in Flutter. GoRouter maps URL paths to screens and
handles deep links automatically — no extra plugins needed.
// pubspec.yaml
dependencies:
go_router: ^14.8.0
import 'package:go_router/go_router.dart';
final router = GoRouter(
initialLocation: '/',
debugLogDiagnostics: true, // Remove in production
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/product/:id',
builder: (context, state) {
final productId = state.pathParameters['id']!;
return ProductScreen(productId: productId);
},
),
GoRoute(
path: '/user/:username',
builder: (context, state) {
final username = state.pathParameters['username']!;
return UserProfileScreen(username: username);
},
),
GoRoute(
path: '/search',
builder: (context, state) {
final query = state.uri.queryParameters['q'] ?? '';
return SearchScreen(initialQuery: query);
},
),
],
);
// In your MaterialApp
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
);
}
}
When a user taps https://yourapp.com/product/42, GoRouter receives /product/42, matches
it against the /product/:id route, extracts 42 as the id parameter, and
renders ProductScreen(productId: '42').
5. Handling Path Parameters and Query Strings
Real-world deep links often include multiple parameters and query strings. Here's how to handle them:
GoRoute(
// Matches: /category/electronics/product/42
path: '/category/:categoryId/product/:productId',
builder: (context, state) {
final categoryId = state.pathParameters['categoryId']!;
final productId = state.pathParameters['productId']!;
// Query params: ?ref=email&campaign=summer
final ref = state.uri.queryParameters['ref'];
final campaign = state.uri.queryParameters['campaign'];
// Track attribution
if (ref != null) {
analytics.trackDeepLink(ref: ref, campaign: campaign);
}
return ProductScreen(
categoryId: categoryId,
productId: productId,
);
},
),
Validating Parameters
Never trust incoming deep link parameters. They come from outside your app — treat them like user input:
GoRoute(
path: '/product/:id',
redirect: (context, state) {
final id = state.pathParameters['id'];
// Validate: must be numeric and reasonable
if (id == null || int.tryParse(id) == null || int.parse(id) < 0) {
return '/'; // Redirect invalid links to home
}
return null; // Valid — proceed to builder
},
builder: (context, state) {
final productId = int.parse(state.pathParameters['id']!);
return ProductScreen(productId: productId);
},
),
6. Deep Linking into Nested Navigation
Most apps have tabbed navigation. Deep linking should land users on the correct tab with the right sub-page.
GoRouter's ShellRoute handles this:
final router = GoRouter(
initialLocation: '/home',
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return MainShell(navigationShell: navigationShell);
},
branches: [
// Tab 0: Home
StatefulShellBranch(
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
path: 'notifications',
builder: (context, state) => const NotificationsScreen(),
),
],
),
],
),
// Tab 1: Search
StatefulShellBranch(
routes: [
GoRoute(
path: '/search',
builder: (context, state) => const SearchScreen(),
),
],
),
// Tab 2: Profile
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
routes: [
GoRoute(
path: 'settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: 'edit',
builder: (context, state) => const EditProfileScreen(),
),
],
),
],
),
],
),
],
);
Now https://yourapp.com/profile/settings opens the Profile tab and pushes the Settings screen on
top. The tab state persists — switching to Home and back to Profile shows Settings still on the stack.
7. Authentication Guards for Deep Links
What happens when a user deep links to /profile/settings but isn't logged in? You need a redirect
that saves the intended destination:
final router = GoRouter(
redirect: (context, state) {
final authState = context.read<AuthCubit>().state;
final isLoggedIn = authState is Authenticated;
final isLoginRoute = state.matchedLocation == '/login';
// Paths that don't require auth
const publicPaths = ['/', '/login', '/signup', '/product'];
final isPublicPath = publicPaths.any(
(path) => state.matchedLocation.startsWith(path),
);
if (!isLoggedIn && !isPublicPath) {
// Save intended destination for post-login redirect
return '/login?redirect=${Uri.encodeComponent(state.uri.toString())}';
}
if (isLoggedIn && isLoginRoute) {
// Already logged in, redirect to intended page or home
final redirect = state.uri.queryParameters['redirect'];
return redirect != null ? Uri.decodeComponent(redirect) : '/home';
}
return null; // No redirect needed
},
routes: [/* ... */],
);
After login, read the redirect query parameter and navigate there. The user lands exactly where they
intended — even if they had to authenticate first.
8. Deferred Deep Linking
Deferred deep linking handles this flow: user clicks a link → app isn't installed → user goes to the app store → installs the app → app opens to the intended content, not just the home screen.
Flutter doesn't have built-in support for deferred deep links. Here's a lightweight approach without third-party services:
// On your web server: landing page redirects to store
// but stores the intended path in a server-side session
// keyed by a unique ID passed as a query parameter
// In your Flutter app: on first launch, check for pending deep link
class DeferredDeepLinkService {
final Dio _dio;
final SharedPreferences _prefs;
DeferredDeepLinkService(this._dio, this._prefs);
Future<String?> checkPendingDeepLink() async {
// Only check once after install
if (_prefs.getBool('deferred_link_checked') == true) return null;
try {
// Your server returns the stored deep link for this device/install
final response = await _dio.get('/api/deferred-link', queryParameters: {
'device_id': await _getDeviceId(),
});
await _prefs.setBool('deferred_link_checked', true);
if (response.statusCode == 200 && response.data['path'] != null) {
return response.data['path'] as String;
}
} catch (_) {
// Network error — skip, don't block app launch
}
return null;
}
}
For a production-grade solution, consider Branch.io or Adjust — they handle the attribution, device fingerprinting, and store redirect logic.
9. Deep Linking on Flutter Web
On Flutter Web, deep linking "just works" because your app is already running in a browser. The URL in the address bar IS the deep link. But there are configuration details that trip people up:
// Use PathUrlStrategy for clean URLs (no hash fragment)
import 'package:flutter_web_plugins/url_strategy.dart';
void main() {
usePathUrlStrategy(); // https://yourapp.com/product/42
// Instead of default: https://yourapp.com/#/product/42
runApp(const MyApp());
}
Server-Side Configuration for Path URLs
With usePathUrlStrategy(), your server needs to return index.html for all routes —
otherwise refreshing /product/42 returns a 404. Configure your web server:
# Nginx
location / {
try_files $uri $uri/ /index.html;
}
# Firebase Hosting (firebase.json)
{
"hosting": {
"rewrites": [
{ "source": "**", "destination": "/index.html" }
]
}
}
10. Testing Deep Links
Android (adb)
# Test App Link
adb shell am start -a android.intent.action.VIEW \
-d "https://yourapp.com/product/42" \
com.yourcompany.yourapp
# Verify App Links are set up correctly
adb shell pm get-app-links com.yourcompany.yourapp
iOS Simulator (xcrun)
# Test Universal Link
xcrun simctl openurl booted "https://yourapp.com/product/42"
Widget Tests with GoRouter
testWidgets('Deep link to product page', (tester) async {
final router = GoRouter(
initialLocation: '/product/42',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/product/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductScreen(productId: id);
},
),
],
);
await tester.pumpWidget(
MaterialApp.router(routerConfig: router),
);
await tester.pumpAndSettle();
// Product screen should be showing
expect(find.byType(ProductScreen), findsOneWidget);
expect(find.text('Product #42'), findsOneWidget);
});
testWidgets('Invalid deep link redirects to home', (tester) async {
final router = GoRouter(
initialLocation: '/product/abc', // Invalid ID
routes: [
GoRoute(path: '/', builder: (_, __) => const HomeScreen()),
GoRoute(
path: '/product/:id',
redirect: (_, state) {
if (int.tryParse(state.pathParameters['id'] ?? '') == null) {
return '/';
}
return null;
},
builder: (_, state) =>
ProductScreen(productId: state.pathParameters['id']!),
),
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
expect(find.byType(HomeScreen), findsOneWidget);
});
11. Troubleshooting Common Issues
Deep linking has a lot of moving parts. Here are the issues I run into most often:
App Links Not Verified on Android
Symptom: The chooser dialog appears instead of opening your app directly.
Fix: Check that assetlinks.json is accessible at
https://yourdomain.com/.well-known/assetlinks.json. It must be served with
Content-Type: application/json and must not have redirects. Run
adb shell pm get-app-links your.package.name — the status should show verified. If it
shows legacy_failure, your SHA-256 fingerprint doesn't match.
Universal Links Open in Safari Instead of the App
Symptom: Tapping a link opens Safari, not your app.
Fix: Universal Links don't work when you type the URL directly in Safari's address bar or when
you tap a link on the same domain. They work from other apps (Messages, Mail, Notes, third-party apps). Also check
that your apple-app-site-association file is valid JSON and served over HTTPS without redirects. Use
Apple's AASA Validator to verify.
GoRouter Doesn't Receive the Deep Link
Symptom: Platform verification works, app opens, but it shows the home screen.
Fix: Make sure you're using MaterialApp.router with
routerConfig: router, not MaterialApp with home:. Check that your GoRouter
route paths match the URL paths in your intent filters / AASA components. Enable
debugLogDiagnostics: true to see which routes GoRouter is matching.
Deep Link Works on First Launch but Not on Subsequent Taps
Symptom: Deep link works when app is cold-started, but not when it's already running in the background.
Fix: Ensure your Android activity has android:launchMode="singleTop" in the
manifest. Without it, Android creates a new activity instance instead of routing to the existing one. GoRouter
handles the onNewIntent callback automatically.
Related Guides
- Flutter Push Notifications with FCM: Complete Implementation Guide
- Firebase Authentication in Flutter: Complete Guide 2026
- Flutter Clean Architecture: A Practical Guide
- Flutter Web in Production: What Actually Works
- GetX vs Bloc vs Riverpod: State Management Comparison 2026
- Flutter Testing Strategy: Unit, Widget, and Integration Tests
- Flutter DevOps: CI/CD Pipeline with GitHub Actions
- Flutter App Security: Protecting User Data
- Flutter Responsive Design: Building Adaptive UIs
- Flutter Internationalization: Complete i18n & l10n Guide
Frequently Asked Questions
What's the difference between deep links and URI scheme links in Flutter?
URI scheme links (myapp://product/123) are custom protocols that only work if the app is
installed and can't be verified for ownership. Deep links using Universal Links (iOS) and App Links (Android)
use HTTPS URLs with domain verification — only the developer who controls the domain can handle those links.
Always prefer Universal/App Links in production.
Do I need a server to set up deep linking?
Yes, for Universal Links and App Links. iOS requires an apple-app-site-association file at
your-domain.com/.well-known/apple-app-site-association, and Android requires an
assetlinks.json at your-domain.com/.well-known/assetlinks.json. These files prove
you own the domain and authorize your app to handle its URLs. Any web server, CDN, or Firebase Hosting works.
Does GoRouter handle deep links automatically?
GoRouter handles the routing part — mapping a URL path to a screen. But you still need to configure the platform side: Universal Links on iOS, App Links on Android, and verification files on your server. GoRouter receives the deep link URI and matches it against your route definitions, but it won't handle the platform handshake.
How do I test deep links without deploying?
On Android, use
adb shell am start -a android.intent.action.VIEW -d 'https://yourdomain.com/product/123' com.your.package.
On iOS simulator, use xcrun simctl openurl booted 'https://yourdomain.com/product/123'. For
GoRouter, write widget tests that set the initial location to any deep link path and verify the correct screen
renders.
What about deferred deep links for users who don't have the app installed?
Deferred deep linking means a user clicks a link, gets redirected to the app store, installs the app, and then lands on the intended content — not the home screen. Flutter doesn't have built-in support for this. You need a service like Branch.io or Adjust, or build a lightweight server-side solution that stores the intended URL and retrieves it on first app launch.
Can I deep link into specific tabs or nested navigation?
Yes. With GoRouter's StatefulShellRoute, you define routes that map to specific tabs. Each tab
can have its own nested navigation stack. For example, /profile/settings opens the Profile tab
and pushes Settings on top. The key is structuring your GoRouter route tree to match your navigation
hierarchy.