1. Declarative Routing with go_router
flutter pub add go_router
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(path: '/', builder: (context, state) => const ProductListScreen()),
GoRoute(
path: '/product/:id',
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return ProductDetailScreen(productId: id);
},
),
],
);
MaterialApp.router(routerConfig: router)
Every route is a real, typed URL path — /product/42 — rather than an
opaque named route with manually-passed arguments, which is what makes deep linking
(this week's last section) work naturally instead of as a bolted-on afterthought.
2. Nested Navigators & Shell Routes
A ShellRoute (or its StatefulShellRoute variant for tabs)
wraps a set of routes in a persistent shell — like the bottom nav bar — while each
child route still gets its own independent push/pop history underneath.
StatefulShellRoute.indexedStack(
builder: (context, state, shell) => MainScreen(shell: shell),
branches: [
StatefulShellBranch(routes: [
GoRoute(path: '/shop', builder: (c, s) => const ProductListScreen()),
GoRoute(path: '/shop/product/:id', builder: (c, s) =>
ProductDetailScreen(productId: int.parse(s.pathParameters['id']!))),
]),
StatefulShellBranch(routes: [
GoRoute(path: '/cart', builder: (c, s) => const CartScreen()),
]),
],
)
This is the fix for the exact gap Week 4 flagged — pushing a product detail screen from the Shop tab now genuinely lives on the Shop tab's own stack, and switching to Cart and back preserves it, instead of the whole app sharing one flat navigation history.
3. Typed Parameters
Raw string path parameters (state.pathParameters['id']) work but push
parsing and validation into every route builder. Wrapping route construction in a
small typed helper cleans that up.
class ProductRoute {
static String path(int id) => '/shop/product/$id';
}
// call sites now read cleanly, with no raw string formatting scattered around
context.go(ProductRoute.path(42));
This is a lighter-weight version of what packages like go_router_builder
generate automatically from annotations — worth adopting once a route table grows
past a handful of routes, but a hand-written helper is enough to see the pattern.
4. Deep Links & Auth-Gated Redirects
Because every route is a real path, a deep link (a push notification, a shared URL,
a universal link) that opens /shop/product/42 lands directly on the
right screen — no extra deep-linking library required, on top of what
go_router already provides.
final router = GoRouter(
redirect: (context, state) {
final isSignedIn = AuthState.of(context).isSignedIn;
final isLoggingIn = state.matchedLocation == '/login';
if (!isSignedIn && !isLoggingIn) return '/login';
if (isSignedIn && isLoggingIn) return '/';
return null; // no redirect needed
},
routes: [ /* ... */ ],
);
This redirect callback runs before every navigation, including a deep
link opening the app cold — so a link to /cart received while signed
out correctly detours through /login first, rather than crashing or
exposing a screen it shouldn't.
5. Hands-on Exercise
Rebuild the app on go_router with a real auth gate
Migrate Week 4's 3-screen app to go_router with shell routes and a redirect.
Requirements:
- A
GoRouterconfiguration replacing Week 4's named routes, with a real/product/:idpath parameter. - A
StatefulShellRoutegiving each bottom-nav tab its own nested stack — confirm pushing a product detail from the Shop tab and switching to Cart and back preserves the Shop tab's stack correctly. - A fake
isSignedInflag and aredirectcallback sending signed-out users to/login, and signed-in users away from/login. - Test opening a deep link directly to
/shop/product/7both while signed in (lands on the product) and signed out (redirects to/loginfirst). - A typed helper for constructing the product route path, used at every call site instead of raw string interpolation.
If the redirect logic seems to loop or never settle, double-check it returns null — not a redirect target — for the "no redirect needed" case; returning a route unconditionally, even one matching the current location, can cause go_router to re-evaluate redirects repeatedly instead of finishing navigation.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does a real path like /product/:id make deep linking work naturally, where Week 4's named routes needed extra handling?
Why does a real path like /product/:id make deep linking work naturally, where Week 4's named routes needed extra handling?
A path parameter is inherently URL-shaped — the exact same route go_router uses for in-app navigation is what a deep link (a notification, a shared link) targets directly, with no separate mapping layer required. Week 4's named routes with manually-passed constructor arguments have no natural URL representation, so deep linking to a specific argument-bearing screen needs bespoke handling on top.
Q2
What problem does StatefulShellRoute solve that a plain bottom nav bar switching between static screens (Week 4's approach) does not?
What problem does StatefulShellRoute solve that a plain bottom nav bar switching between static screens (Week 4's approach) does not?
It gives each tab a genuinely independent navigation stack — pushing a screen within one tab and switching to another tab and back correctly preserves exactly where you were in the first tab's stack. Week 4's approach just swaps which top-level widget is shown, with no real per-tab push/pop history underneath at all.
Q3
Why does the redirect callback need to check both "signed out and not already going to /login" and "signed in and going to /login"?
Why does the redirect callback need to check both "signed out and not already going to /login" and "signed in and going to /login"?
Without the first check, a signed-out user could reach any protected route directly. Without the second, a signed-in user could still navigate back to /login and see a sign-in screen while already authenticated, which is confusing and typically wrong UX — both directions need to be handled for the gate to be complete, not just the obvious "block unauthenticated access" direction.
Q4
Why does testing a deep link while signed out matter as its own explicit test case, separate from testing normal in-app navigation to the same screen?
Why does testing a deep link while signed out matter as its own explicit test case, separate from testing normal in-app navigation to the same screen?
A deep link can open the app cold, before any in-app navigation state exists, which exercises the redirect callback's very first evaluation — a bug that only shows up on that cold-start path (like the login state not being initialized yet when redirect first runs) would never surface from testing in-app taps alone, since the app is already warm and initialized by that point.