Mastering Nested Navigation in Flutter: Bottom Navigation, ShellRoutes, and Tab Preservation
A complete practical guide to nested navigation in Flutter using GoRouter and StatefulShellRoute: preserving tab scroll positions, deep linking, and responsive side rails.
Published on • October 27, 2026
AI Assistant

Almost every modern mobile and desktop application features a persistent bottom navigation bar or a side navigation rail. Users expect:
- Each tab to maintain its own independent navigation history stack.
- Scroll positions and form inputs to remain intact when switching between tabs.
- Deep links (e.g.,
/settings/accountor/feed/item/42) to open the correct tab with the proper back-stack intact.
Accomplishing this using traditional imperative Navigator.push() or vanilla Navigator widgets often involved hundreds of lines of fragile boilerplate.
In modern Flutter architectures using go_router, the canonical solution is StatefulShellRoute. In this guide, we break down the architecture and implement a production-grade nested navigation system.
1. The Anatomy of Nested Navigation
In a flat routing system, pushing a detail screen replaces the entire view, hiding the bottom navigation bar. In a nested navigation structure, the outer scaffold (the “Shell”) remains mounted on the screen, while child navigation branches swap inside a dedicated content container:
+--------------------------------------------------------+
| App Shell (Persistent Header / NavigationBar) |
| |
| +--------------------------------------------------+ |
| | Active Tab Branch (Independent Navigator) | |
| | | |
| | [FeedScreen] ---> Pushed ---> [PostDetailScreen] | |
| +--------------------------------------------------+ |
| |
| [Tab 1: Feed] [Tab 2: Search] [Tab 3: Profile] |
+--------------------------------------------------------+
2. Implementing StatefulShellRoute.indexedStack
go_router provides StatefulShellRoute.indexedStack, which places each branch’s child navigator inside an IndexedStack. This guarantees that when a user switches tabs, the inactive tabs remain alive in memory, preserving their scroll offsets and state.
Here is the complete routing configuration:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>();
final GoRouter appRouter = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/feed',
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
// Return the persistent UI shell displaying the navigationShell container
return ScaffoldWithNestedNavigation(navigationShell: navigationShell);
},
branches: [
// Branch 1: Feed Tab
StatefulShellBranch(
routes: [
GoRoute(
path: '/feed',
builder: (context, state) => const FeedScreen(),
routes: [
GoRoute(
path: 'detail/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return FeedDetailScreen(itemId: id);
},
),
],
),
],
),
// Branch 2: Search Tab
StatefulShellBranch(
routes: [
GoRoute(
path: '/search',
builder: (context, state) => const SearchScreen(),
),
],
),
// Branch 3: Profile Tab
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
],
),
],
),
],
);
3. Building the Responsive Scaffold Shell
The shell widget receives navigationShell as a parameter. It reads navigationShell.currentIndex to highlight the active tab, and calls navigationShell.goBranch(index) when a user selects a destination:
class ScaffoldWithNestedNavigation extends StatelessWidget {
final StatefulNavigationShell navigationShell;
const ScaffoldWithNestedNavigation({
super.key,
required this.navigationShell,
});
void _onDestinationSelected(int index) {
navigationShell.goBranch(
index,
// If tapping the already-active tab, pop to the root of that branch
initialLocation: index == navigationShell.currentIndex,
);
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 768;
if (isDesktop) {
// Responsive layout: Side NavigationRail for tablet/desktop
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: _onDestinationSelected,
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(icon: Icon(Icons.home), label: Text('Feed')),
NavigationRailDestination(icon: Icon(Icons.search), label: Text('Search')),
NavigationRailDestination(icon: Icon(Icons.person), label: Text('Profile')),
],
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: navigationShell),
],
),
);
}
// Standard BottomNavigationBar for mobile
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: _onDestinationSelected,
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Feed'),
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
4. Full-Screen Modals Outside the Shell
What if you need to display a full-screen checkout, video player, or login modal that hides the navigation shell entirely?
Simply set parentNavigatorKey: _rootNavigatorKey on that specific route:
GoRoute(
parentNavigatorKey: _rootNavigatorKey, // Overrides shell; takes over entire window
path: '/checkout',
builder: (context, state) => const FullScreenCheckoutScreen(),
),
5. Architectural Best Practices
| Pattern | Implementation | Benefit |
|---|---|---|
| Tab Re-tap Behavior | navigationShell.goBranch(index, initialLocation: true) | Pops sub-pages back to branch root when tab is tapped twice |
| Deep Link Sync | Paths mapped directly inside branches | Browsing to /feed/detail/99 switches to Tab 0 and opens detail |
| Adaptive Layouts | Switch between NavigationBar and NavigationRail | Flawless UX across phones, foldables, tablets, and desktop |
| State Preservation | indexedStack keeps widgets in element tree | Scroll offsets and form inputs are never lost |
By adopting StatefulShellRoute for nested navigation, your Flutter applications achieve the fluid, polished navigation UX expected of top-tier consumer and enterprise applications.