Architecture Field Guides
LLM rule View .md
Flutter · GoRouter 17.x · Declarative routing

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.

/orders/42
Stack
Flutter + GoRouter + Riverpod
Style
Plain Dart, feature-first
Reading time
~30 minutes
Chapter 01 / The mental model

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.

Imperative · Navigator
You manage the stack

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.

Declarative · GoRouter
The location drives the stack

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

Chapter 02 / Route identity

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.

Compare:
// 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));
Why not go_router_builder?

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.

Chapter 03 / The route tree

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.

Absolute vs relative paths

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.

Chapter 04 / Nested navigation

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.

stateful_shell · live
location: /home branch 0
State preservation indexedStack
00:00 [shell] mounted · branch 0 active
// 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,
    );
  }
}
The lifecycle catch

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.

Chapter 06 / Redirects and guards

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

BuildContext after an await

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.

Chapter 07 / Common pitfalls

Where routing trips people.

Most GoRouter bugs are one of these. They're easy to avoid once you've seen them named.

Pitfall 01

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.

Pitfall 02

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).

Pitfall 03

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.

Pitfall 04

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.

Pitfall 05

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.

Pitfall 06

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.