Deep Linking with go_router
4 min read
Navigation
User taps myapp.com/products/123
│
├── App not running? OS launches it WITH the URL → main() → router parses
│
└── App running? OS routes URL to existing instance → router parses
↓
ProductScreen(id: '123')
Three things must line up:
- OS-level claim: your app declares "I handle myapp.com / com.example://".
- Verification (iOS Universal Links / Android App Links): server-hosted
apple-app-site-association/assetlinks.jsonfiles prove you own the domain. - In-app routing: go_router converts the URL into a route stack.
Without #1/#2, the URL opens in the browser. Without #3, you get the launch screen but no navigation.
Code in action
final router = GoRouter(
initialLocation: '/',
debugLogDiagnostics: true,
routes: [
GoRoute(
path: '/',
builder: (ctx, st) => const HomeScreen(),
routes: [
GoRoute(
name: 'product',
path: 'products/:id', // path parameter
builder: (ctx, st) => ProductScreen(
productId: st.pathParameters['id']!,
),
),
GoRoute(
path: 'profile',
builder: (ctx, st) => const ProfileScreen(),
routes: [
GoRoute(
path: 'settings',
builder: (ctx, st) => const SettingsScreen(),
),
],
),
],
),
],
// Auth guard
redirect: (ctx, st) {
final loggedIn = AuthService.instance.isLoggedIn;
final atLogin = st.matchedLocation == '/login';
if (!loggedIn && !atLogin) return '/login?from=${st.matchedLocation}';
if (loggedIn && atLogin) return '/';
return null;
},
errorBuilder: (ctx, st) => const NotFoundScreen(),
);
// Navigation
context.go('/products/123'); // replace stack
context.push('/products/123'); // push on top
context.goNamed('product', pathParameters: {'id': '123'});
Platform setup (the part people forget)
Android — AndroidManifest.xml:
<activity android:name=".MainActivity" ...>
<!-- App link (verified) -->
<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>
- Host
https://myapp.com/.well-known/assetlinks.jsonproving ownership.
iOS — Runner.entitlements:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:myapp.com</string>
</array>
- Host
https://myapp.com/.well-known/apple-app-site-association.
Web: go_router uses the URL directly — pushState integration is automatic.
Where to handle which
| Concern | Use |
|---|---|
| Auth gating | redirect: at GoRouter root |
| Per-route guards | Per-route redirect: |
| 404 / unknown URL | errorBuilder: |
| Loading state while resolving (e.g., wait for auth) | A wrapper widget around the route's child |
| Push from notification when app is in foreground | context.push(notificationDeepLink) |
| Push from notification when app was killed | getInitialMessage() in your init flow → router.go(...) once app is ready |
| Branch.io / Adjust / Firebase Dynamic Links | Use the SDK to receive the URL, then forward to router.go() |
Common mistakes to avoid
❌ Skipping platform configuration
Code looks right; URL opens browser instead of app. The OS doesn't know
your app claims that URL.
❌ Forgetting the .well-known files for Universal Links / App Links
Without verification, iOS shows a "open in app?" prompt or just opens Safari.
❌ Auth redirect that returns the wrong destination after login
User taps a deep link → redirected to /login → after login → home (lost their target).
✅ Preserve the original path: '/login?from=$matchedLocation', then redirect back.
❌ context.go for a flow that should be pushable
go REPLACES the stack — back button takes you out of the app instead of back to home.
✅ Use push for nested flows; go for "go here" operations like logout.
❌ Trying to access ScopedDependencies before they're ready
Deep link fires before Firebase / DI is initialized.
✅ Show a SplashRoute that awaits init, then redirects to the original path.
❌ Reusing path parameter names across nested routes
/:id at two levels → both populate state.pathParameters['id'], you lose info.
✅ Use distinct names: '/orders/:orderId/items/:itemId'.
Interview follow-ups
-
How do you preserve the user's intended destination through an auth redirect? Capture
st.matchedLocationin theredirect, encode it as a query parameter (/login?from=...), and after successful logincontext.go(decoded). Standard "deep link survives login" pattern. -
How do you handle a deep link that arrives before the app is initialized? For terminated state, read
FirebaseMessaging.getInitialMessage()(orgetInitialLink()fromuni_links/app_links) inside your app's init phase. Show a splash while you initialize, thenrouter.go(deepLinkPath)once dependencies are ready. -
What's the difference between scheme-based and Universal/App Links?
myapp://...is a custom URL scheme — works without verification but can be hijacked by any app claiming the same scheme. Universal Links (iOS) / App Links (Android) use HTTPS URLs and require server-hosted verification files — they're tied to your domain, so no other app can claim them. -
When would you NOT use go_router? For tiny apps with 2-3 imperative pushes, plain
Navigatoris fine. For very custom Router 2.0 setups (parallel navigators, exotic state restoration), the lower-levelRouterAPI may give more control. For the 90% case — auth flow, web, deep links, nested layouts — go_router is the right default.
How helpful was this content?
Please sign in to rate this article.