Skip to content
Blog

Shell Routes in go_router: Nested Navigation Without Losing Your App Bar

How ShellRoute and StatefulShellRoute keep persistent navigation chrome while inner routes push and pop — plus redirects, parentNavigatorKey, and push vs go behavior inside shells.

Published on • October 9, 2026

AI Assistant

Every Flutter app with a bottom bar hits the same wall: the BottomNavigationBar should stay on screen while the pages underneath change. Push a detail route and the bar disappears; pop back and it flickers. The fix in go_router is the shell route — a wrapper route that displays persistent chrome around matching child routes.

What a ShellRoute actually does

A ShellRoute “displays a UI shell around the matching child route.” When you add one to your routes, a new Navigator is used for matching sub-routes instead of the root Navigator. The matched child widget arrives as the child parameter of the shell’s builder, and you embed it inside your scaffold:

final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>();

final GoRouter _router = GoRouter(
  navigatorKey: _rootNavigatorKey,
  initialLocation: '/a',
  routes: [
    ShellRoute(
      navigatorKey: _shellNavigatorKey,
      builder: (context, state, child) => ScaffoldWithNavBar(child: child),
      routes: [
        GoRoute(
          path: '/a',
          builder: (context, state) => const ScreenA(),
          routes: [
            GoRoute(
              path: 'details',
              builder: (context, state) => const DetailsScreen(label: 'A'),
            ),
          ],
        ),
        GoRoute(
          path: '/b',
          builder: (context, state) => const ScreenB(),
          routes: [
            GoRoute(
              path: 'details',
              parentNavigatorKey: _rootNavigatorKey, // renders OUTSIDE the shell
              builder: (context, state) => const DetailsScreen(label: 'B'),
            ),
          ],
        ),
      ],
    ),
  ],
);

Notice the parentNavigatorKey: _rootNavigatorKey escape hatch: routes that must cover the shell entirely (full-screen dialogs, login flows) opt out by pointing back at the root navigator.

ShellRoute vs. StatefulShellRoute

Here is the trap: a plain ShellRoute has one nested Navigator shared by all its sub-routes. Switching tabs with go() replaces that stack — so each tab loses its scroll position, stack depth, and state.

StatefulShellRoute creates a separate Navigator per StatefulShellBranch, preserving each branch independently:

StatefulShellRoute.indexedStack(
  builder: (context, state, navigationShell) =>
      ScaffoldWithNavBar(navigationShell: navigationShell),
  branches: [
    StatefulShellBranch(routes: [
      GoRoute(path: '/a', builder: (c, s) => const RootScreen(label: 'A'), routes: [
        GoRoute(path: 'details', builder: (c, s) => const DetailsScreen(label: 'A')),
      ]),
    ]),
    StatefulShellBranch(routes: [
      GoRoute(path: '/c', builder: (c, s) => const RootScreen(label: 'C')),
    ]),
  ],
);

// In your shell widget:
void _onItemTapped(int index) => navigationShell.goBranch(index);

Key differences:

ShellRouteStatefulShellRoute
NavigatorsOne sharedOne per branch
Tab stateLost on switchPreserved
Builder receiveschild widgetStatefulNavigationShell
Tab switchingcontext.go()navigationShell.goBranch(index)
Variants—default (you own layout) or .indexedStack

StatefulShellRoute.indexedStack supplies an IndexedStack container — the right default for most apps. The plain constructor requires a navigatorContainerBuilder if you want custom layout, offstage behavior, or animated transitions between branches. Branches can set initialLocation and preload: true to pre-warm another tab’s initial route.

The cost: indexedStack keeps all branch navigators alive, so offstage widgets still exist in memory.

Redirects and shells

Redirects run at two levels:

  • Top-level redirect on GoRouter — runs before any navigation event.
  • Route-level redirect on GoRoute or ShellRoute — runs when that route is about to display.

Return a path to redirect, null to proceed. Redirect loops are capped by redirectLimit (default 5) — exceeding it shows the error screen.

Because the shell itself is a route, a redirect on the shell applies to all of its children, while a redirect on a child does not re-render the shell. That makes a parent shell route the right place to enforce auth once for every screen beneath it.

push vs. go inside a shell

This distinction trips people up:

  • context.go() replaces the current stack with the destination’s configured screens.
  • context.push() / context.pop() are imperative and stack on top.

Documented shell-aware push behavior:

  1. Push a route with no shell → placed entirely on top, covering the shell.
  2. Push with the same shell → placed inside the shell (bar stays visible).
  3. Push with a different shell → shell + screen go on top.

Two warnings worth remembering:

  • Pages pushed with Navigator.of(context).push(...) are not deep-linkable and get replaced when a later go() removes their parent route.
  • Imperative navigation is known to cause issues with browser history on the web (flutter/flutter#99112). To suppress history entries: Router.neglect(context, () => context.go('/destination')).

Gotchas

  • Plain ShellRoute + tab switching = lost state. Reach for StatefulShellRoute whenever tabs have their own stack.
  • Branch navigator keys must be unique — the constructor asserts this.
  • WillPopScope is incompatible with Router-based APIs; use PopScope or router-level redirects instead.
  • go_router is declared feature-complete — the Flutter team now focuses on bug fixes and stability, so the shell APIs above are a safe long-term bet.

Wrapping up

Shell routes separate “app chrome” from “page stack.” Use ShellRoute for simple persistent UI, StatefulShellRoute.indexedStack when tabs must remember where they were, parentNavigatorKey for full-screen escapes, and route-level redirects for auth guards that cover whole subtrees. Get those four levers right and nested navigation stops fighting you.

References