Week 5: go_router, Nested Navigation & Deep Linking

Week 4's Navigator works, but real apps need URL-style paths, type-safe parameters, and a tab bar whose tabs each keep their own navigation history — exactly what go_router, the community-standard declarative router, is built for. This week rebuilds last week's app on top of it properly.

Phase 3 of 8 Week 5 of 20 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Set up go_router with a declarative route configuration
  • Build shell routes so each tab in a bottom nav bar keeps its own nested navigation stack
  • Pass typed parameters through a route path
  • Configure an auth-gated redirect that sends signed-out users to a login route automatically

1. Declarative Routing with go_router

terminal
flutter pub add go_router
router.dart
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);
      },
    ),
  ],
);
main.dart — MaterialApp.router instead of MaterialApp
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.

a shell route per tab, each with its own stack
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.

a typed navigation helper
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.

redirecting signed-out users to /login
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

Hands-on

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:

  1. A GoRouter configuration replacing Week 4's named routes, with a real /product/:id path parameter.
  2. A StatefulShellRoute giving 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.
  3. A fake isSignedIn flag and a redirect callback sending signed-out users to /login, and signed-in users away from /login.
  4. Test opening a deep link directly to /shop/product/7 both while signed in (lands on the product) and signed out (redirects to /login first).
  5. A typed helper for constructing the product route path, used at every call site instead of raw string interpolation.
Hint

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?

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?

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"?

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?

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.