Deep linking is the feature that looks trivial in a demo and eats a sprint in production. go_router routes a URL in five minutes on a simulator; then marketing sends a campaign link, half the Android users land on the home screen, iOS opens Safari instead of the app, and a link tapped by a logged-out user drops them at the login screen and forgets where they were going.
This tutorial covers the whole path a link travels: platform verification files, native manifest configuration, a go_router setup with an auth redirect that preserves intent, and the commands we use to test links on real devices. Code is go_router 14.x on Flutter 3.4x.
The three kinds of links (they are not the same)
- Custom scheme —
myapp://work-orders/42. Works instantly, no server setup, but any app can register the same scheme and it does nothing when the app isn't installed. Fine for OAuth callbacks, wrong for marketing. - Android App Links —
https://example.com/work-orders/42verified against aassetlinks.jsonfile on your domain. Opens the app directly, no disambiguation dialog. - iOS Universal Links — the same HTTPS URL verified against
apple-app-site-association. Opens the app if installed, otherwise the web page.
Ship HTTPS links. Keep a custom scheme only for auth callbacks. Whatever you choose, the same URL must render a real page on the web, because links get opened on desktops.
Server side: the two verification files
Both files must be served over HTTPS, with no redirects, and Content-Type: application/json.
https://example.com/.well-known/assetlinks.json:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": [
"AB:CD:...:EF"
]
}
}]
The fingerprint must be the signing key that Google Play uses, which is usually the Play App Signing key, not your upload key. Copy it from Play Console → Setup → App integrity. If you also distribute internal builds signed with a different key, list both fingerprints.
https://example.com/.well-known/apple-app-site-association (no .json extension):
{
"applinks": {
"details": [{
"appIDs": ["TEAMID.com.example.app"],
"components": [
{ "/": "/work-orders/*", "comment": "work order detail" },
{ "/": "/invite/*" },
{ "/": "/admin/*", "exclude": true }
]
}]
}
}
Verify both with curl -I before touching the app. A CDN that adds text/html or a 301 from example.com to www.example.com is the single most common cause of "it works on my machine".
Native configuration
Android — in android/app/src/main/AndroidManifest.xml, inside the main activity, add a second intent filter with android:autoVerify="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="example.com" />
</intent-filter>
Keep the launcher intent filter separate; merging them breaks the app icon. Also add <meta-data android:name="flutter_deeplinking_enabled" android:value="true" /> so Flutter's built-in deep link handling forwards the URL to your router.
iOS — add the Associated Domains capability in Xcode with applinks:example.com, and set FlutterDeepLinkingEnabled to true in Info.plist.
Router: routes that mirror your URLs
final routerProvider = GoRouter(
initialLocation: '/',
navigatorKey: rootNavigatorKey,
refreshListenable: authState, // re-runs redirect on login/logout
routes: [
GoRoute(path: '/', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(
path: '/work-orders/:id',
builder: (context, state) =>
WorkOrderScreen(id: state.pathParameters['id']!),
routes: [
GoRoute(
path: 'photos', // /work-orders/42/photos
builder: (context, state) =>
PhotosScreen(id: state.pathParameters['id']!),
),
],
),
],
errorBuilder: (context, state) => NotFoundScreen(uri: state.uri),
);
Nesting matters: because photos is a child route, context.pop() from a deep-linked photo screen lands on the work order, not on a dead end. A flat route list gives cold-started users a back button that exits the app.
The auth redirect that preserves intent
This is where most implementations lose the link. The rule: when you bounce an unauthenticated user to login, carry the destination, and when they authenticate, send them there.
redirect: (context, state) {
final loggedIn = authState.isLoggedIn;
final goingToLogin = state.matchedLocation == '/login';
if (!loggedIn && !goingToLogin) {
final from = Uri.encodeComponent(state.uri.toString());
return '/login?from=$from';
}
if (loggedIn && goingToLogin) {
final from = state.uri.queryParameters['from'];
return from != null ? Uri.decodeComponent(from) : '/';
}
return null;
},
Two details that bite: return null (not the current location) when no redirect is needed, or you can loop; and while auth state is still loading, redirect everything to a /splash route instead of /login, otherwise a cold start with a valid token flashes the login screen and discards the link.
Cold start vs. warm start
A warm start delivers the URL through the platform's link stream while your app is alive. A cold start delivers it as the initial route before your first frame. go_router handles both when FlutterDeepLinkingEnabled / flutter_deeplinking_enabled are set — but only if you do not hardcode initialLocation logic that overrides it, and only if any splash/bootstrap screen re-issues the pending location after initialization instead of calling go('/').
If you need to intercept links yourself (for example to strip campaign parameters before routing), use app_links:
final appLinks = AppLinks();
final initial = await appLinks.getInitialLink(); // cold start
appLinks.uriLinkStream.listen(_handle); // warm start
Testing links without publishing a campaign
# Android: simulate an external link
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://example.com/work-orders/42"
# Android: check verification status
adb shell pm get-app-links com.example.app
# iOS simulator
xcrun simctl openurl booted "https://example.com/work-orders/42"
pm get-app-links printing verified for your domain is the only proof that App Links are working; none or legacy_failure means the assetlinks.json fetch failed, usually TLS, redirect, or fingerprint. On iOS, delete and reinstall the app after changing the AASA file — the system caches it, and toggling Developer → Associated Domains Development in Settings makes iterating faster.
Add one integration test per critical link so the routing table can't silently regress:
testWidgets('deep link opens work order', (tester) async {
final router = buildRouter(auth: FakeAuth.loggedIn());
router.go('/work-orders/42');
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
expect(find.text('Work order 42'), findsOneWidget);
});
Checklist before you hand links to marketing
- Both verification files return 200, JSON content type, no redirect.
- Play App Signing fingerprint is in
assetlinks.json. adb shell pm get-app-linksreportsverifiedon a release build.- Cold start, warm start, and killed-app-from-notification all route correctly.
- Logged-out deep link survives the login round trip.
- Every deep-linkable route has a working web equivalent for desktop users.
- Unknown paths hit
errorBuilder, not a crash.
Deep links are cheap to get right at the start of a project and expensive to retrofit once the URL scheme is public. If you want a second pair of eyes on your navigation architecture, or Flutter engineers who have shipped this on both stores, get in touch.