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:
ShellRoute | StatefulShellRoute | |
|---|---|---|
| Navigators | One shared | One per branch |
| Tab state | Lost on switch | Preserved |
| Builder receives | child widget | StatefulNavigationShell |
| Tab switching | context.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
GoRouteorShellRoute— 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:
- Push a route with no shell → placed entirely on top, covering the shell.
- Push with the same shell → placed inside the shell (bar stays visible).
- 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 latergo()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 forStatefulShellRoutewhenever tabs have their own stack. - Branch navigator keys must be unique — the constructor asserts this.
WillPopScopeis incompatible with Router-based APIs; usePopScopeor 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
- go_router on pub.dev - Flutter Favorite router, currently 18.x
- ShellRoute API docs - shell route semantics and builder signature
- Navigation topic - push vs go, shell-aware push behavior, neglect
- Redirection topic - redirect levels and redirectLimit
- StatefulShellRoute example - official branch-based example