One tree of routes,
the URL as truth.
How we route: a single declarative tree that maps every location to a screen, nested tabs that keep their own state through StatefulShellRoute.indexedStack, and route identity that lives on the screen itself, so there's never a stray path string to mistype.
Describe the map, don't drive it.
The old way is imperative: you call Navigator.push and manually manage a stack of screens. GoRouter flips it. You declare the entire route tree once, and the current location (a URL like /orders/42) becomes the single source of truth for what's on screen. Navigating is just changing the location; GoRouter rebuilds the stack to match.
Every screen pushes and pops by hand. Deep links, web URLs, and back-button behavior are yours to wire up. Two screens that need the same destination each call push with their own MaterialPageRoute.
Hard to deep-link, easy to desync.
You define routes as a tree. A location maps to a stack automatically, so a link to /orders/42 rebuilds Orders with the detail on top, for free. The same tree powers web URLs, deep links, and the back button.
Deep links come almost for free.
The pieces you'll use
- GoRoute: one entry in the tree. A path, a name, and a builder that returns the screen.
- Nested routes: a routes: list under a parent builds a stack, so /orders/42 sits on top of /orders.
- StatefulShellRoute: parallel branches that each keep their own navigation stack and state, the engine behind a bottom nav bar.
- redirect: a function that can send a location somewhere else before it resolves, the hook for auth guards.
The screen owns its address.
A route is identified by two things: a name for navigating to it, and a location (path) for the URL. Both belong to the screen they point at, declared as static constants on the widget. The router and every navigation call reference those, so a path string exists in exactly one place and can never drift.
// features/auth/views/login_screen.dart class LoginScreen extends StatelessWidget { const LoginScreen({super.key}); static const String routeName = 'login'; static const String routeLocation = '/login'; @override Widget build(BuildContext context) => const Scaffold(/* ... */); } // the route definition references the screen's own constants GoRoute( name: LoginScreen.routeName, path: LoginScreen.routeLocation, builder: (context, state) => const LoginScreen(), ), // and so does every navigation call. no loose strings, anywhere. context.go(LoginScreen.routeLocation); context.goNamed(LoginScreen.routeName);
Rename the path once on the screen and the whole app follows. Your IDE's "find references" shows every place that navigates here. A typo becomes a compile error, not a silent 404.
// the same routes, with paths typed out by hand wherever they're needed GoRoute(path: '/login', builder: (c, s) => const LoginScreen()), // elsewhere, in a dozen widgets: context.go('/login'); context.go('/Login'); // typo: silently navigates nowhere context.go('/log-in'); // someone guessed the path // rename the route? now grep the codebase and pray.
Every literal is a chance to mistype, and there's no compiler to catch it. Renaming means a find-and-replace across files, hoping nothing was spelled slightly differently.
Parameterized routes keep the same rule
When a screen takes a path parameter, the location const holds the template, and a small helper builds a concrete location so call sites still never assemble a path by hand:
// features/orders/views/order_detail_screen.dart class OrderDetailScreen extends StatelessWidget { const OrderDetailScreen({required this.orderId, super.key}); final String orderId; static const String routeName = 'orderDetail'; static const String routeLocation = '/orders/:id'; // the template // build a real location with the id filled in static String locationFor(String id) => '/orders/$id'; @override Widget build(BuildContext context) => const Scaffold(/* ... */); } // navigate by name with params (cleanest), or by the helper context.goNamed(OrderDetailScreen.routeName, pathParameters: {'id': order.id}); context.go(OrderDetailScreen.locationFor(order.id));
Typed routes via go_router_builder give compile-time safety, but they're code generation with a build step. This static-const convention gives you most of the same payoff, no loose strings, find-all-references, rename-in-one-place, with zero generated files and nothing to run. It's the hand-written version of type-safe routing.
Assemble it, feature by feature.
The router is one tree, but you don't write it as one giant file. Each feature exposes its own routes, and a central appRouter composes them. Click any node to see how it's defined.
Top-level routes and branch roots use absolute paths (/orders). A nested child uses a relative segment (:id, not /orders/:id), and GoRouter stacks it under its parent. The child screen's routeLocation const still holds the full template for documentation and for context.go, while its GoRoute.path is just the trailing segment. Navigating by routeName sidesteps the distinction entirely.
Tabs that keep their place.
A bottom nav bar where each tab remembers its scroll position, its form input, and how deep you'd drilled, that's StatefulShellRoute.indexedStack. It gives each tab its own independent navigator, all kept alive at once in an IndexedStack. Try it: drill in, switch tabs, come back. Then flip preservation off to feel why it matters.
// core/router/app_router.dart StatefulShellRoute.indexedStack( // the shell is built once; navigationShell drives the tabs builder: (context, state, navigationShell) => ScaffoldWithNavBar(navigationShell: navigationShell), branches: [ StatefulShellBranch(routes: [ GoRoute( name: HomeScreen.routeName, path: HomeScreen.routeLocation, // '/home' builder: (c, s) => const HomeScreen(), ), ]), StatefulShellBranch(routes: [ GoRoute( name: OrdersScreen.routeName, path: OrdersScreen.routeLocation, // '/orders' builder: (c, s) => const OrdersScreen(), routes: [ GoRoute( name: OrderDetailScreen.routeName, path: ':id', // relative, stacks on Orders builder: (c, s) => OrderDetailScreen( orderId: s.pathParameters['id']!, ), ), ], ), ]), StatefulShellBranch(routes: [ GoRoute( name: ProfileScreen.routeName, path: ProfileScreen.routeLocation, // '/profile' builder: (c, s) => const ProfileScreen(), ), ]), ], ),
The shell widget
The builder hands you a StatefulNavigationShell. You drop it in as the body and drive the tabs with goBranch. No selected-index state of your own; the shell holds it.
// core/router/scaffold_with_nav_bar.dart class ScaffoldWithNavBar extends StatelessWidget { const ScaffoldWithNavBar({required this.navigationShell, super.key}); final StatefulNavigationShell navigationShell; @override Widget build(BuildContext context) { return Scaffold( body: navigationShell, // the IndexedStack of branches bottomNavigationBar: NavigationBar( selectedIndex: navigationShell.currentIndex, onDestinationSelected: _goBranch, destinations: const [ NavigationDestination(icon: Icon(Icons.home), label: 'Home'), NavigationDestination(icon: Icon(Icons.receipt), label: 'Orders'), NavigationDestination(icon: Icon(Icons.person), label: 'Profile'), ], ), ); } void _goBranch(int index) { navigationShell.goBranch( index, // tap the active tab again to pop that branch back to its root initialLocation: index == navigationShell.currentIndex, ); } }
Because every branch stays alive in the IndexedStack, screens in background tabs are not disposed when you switch away. Their controllers keep running. That's exactly what preserves state, but it means a heavy stream or animation on an inactive tab is still consuming resources. Pause expensive work when a tab isn't visible if it matters.
Send them where they belong.
Auth is the classic case: a signed-out user heading for a private screen should land on login instead. GoRouter's redirect runs before a location resolves and can return a different location, or null to proceed. Wire it to your Riverpod auth state so it re-evaluates the moment that state changes.
// core/router/app_router.dart (plain Dart, no code-gen) final routerProvider = Provider<GoRouter>((ref) { // a Listenable the router refreshes on, fed by auth state final refresh = ValueNotifier<int>(0); ref.listen(authControllerProvider, (_, __) => refresh.value++); ref.onDispose(refresh.dispose); return GoRouter( initialLocation: SplashScreen.routeLocation, refreshListenable: refresh, // re-runs redirect when auth changes redirect: (context, state) { final loggedIn = ref.read(authControllerProvider).valueOrNull != null; final atLogin = state.matchedLocation == LoginScreen.routeLocation; if (!loggedIn && !atLogin) return LoginScreen.routeLocation; if (loggedIn && atLogin) return HomeScreen.routeLocation; return null; // no redirect, let it through }, routes: [ /* the tree from Chapter 3 */ ], ); }); // wire it into the app MaterialApp.router(routerConfig: ref.watch(routerProvider));
How it reads
- The redirect uses route constants for both the check (state.matchedLocation == LoginScreen.routeLocation) and the destination, so there's still no loose string.
- refreshListenable is what makes it reactive: when the user signs in or out, the notifier ticks and GoRouter re-runs the redirect, moving them automatically.
- Per-route redirects exist too: add a redirect: on an individual GoRoute to guard just that subtree.
If you navigate after an await (common right after a sign-in call), the widget may have rebuilt or been disposed by the time you call context.go. Guard it with a mounted check, or drive the move through the redirect by updating auth state instead of navigating imperatively. The redirect approach avoids the stale-context problem entirely.
Where routing trips people.
Most GoRouter bugs are one of these. They're easy to avoid once you've seen them named.
Using context.go to switch tabs
Inside a stateful shell, context.go('/orders') resets that branch to its root and drops its stack. Switch tabs with navigationShell.goBranch(index) so each tab keeps where it was.
Forgetting to name your routes
Beyond breaking goNamed, unnamed routes also vanish from analytics: PostHog's PosthogObserver records screen views by route name, so an unnamed route is a hole in your funnel. Give every GoRoute a name (the convention in Chapter 2 does this for free).
Assuming background tabs are disposed
With indexedStack, inactive tabs stay alive to preserve state, so dispose does not fire on tab switch. Don't rely on it to stop timers or streams; pause them yourself when a tab loses focus.
Trusting deep-link parameters
state.pathParameters['id'] is whatever was in the URL, including garbage from a stale or hand-typed link. Validate it and route to a not-found screen on failure, especially when the id triggers a fetch that might 404.
push when you meant go
push stacks a new screen every time, so tapping a nav item repeatedly piles up duplicates the user has to back through. Use go for section changes and reserve push for flows you explicitly pop off.
Absolute paths on nested children
A child route's path should be the relative segment (:id), not the full path (/orders/:id). Get this wrong and the route either doesn't match or doesn't stack the way you expect. Keep the full path on the screen's routeLocation const, the segment on the GoRoute.