Nested navigation in Flutter
By Ann Tech · 19 May 2025
Nested navigation means having multiple, independent navigation stacks active at the same time. The most common case: a BottomNavigationBar where each tab has its own back stack. Tapping Back in the Orders tab should go to the previous Orders screen, not the previous tab.
The challenge with Flutter's default Navigator
Flutter has one root Navigator. If you push routes through it, the back button pops in the order they were pushed globally — tab A, tab B, tab A again all share one stack. To get per-tab stacks, you need nested Navigator widgets.
Option 1: Manual nested Navigators
class MainScaffold extends StatefulWidget {
const MainScaffold({super.key});
@override
State<MainScaffold> createState() => _MainScaffoldState();
}
class _MainScaffoldState extends State<MainScaffold> {
int _currentTab = 0;
// One GlobalKey per tab to preserve Navigator state
final List<GlobalKey<NavigatorState>> _navigatorKeys = [
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
];
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
final canPop = _navigatorKeys[_currentTab].currentState!.canPop();
if (canPop) {
_navigatorKeys[_currentTab].currentState!.pop();
return false; // Don't pop the root navigator
}
return true; // Let the root handle it (exit app)
},
child: Scaffold(
body: Stack(
children: [
_buildTab(0, const HomeTab()),
_buildTab(1, const OrdersTab()),
_buildTab(2, const ProfileTab()),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentTab,
onTap: (index) => setState(() => _currentTab = index),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.list), label: 'Orders'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
),
);
}
Widget _buildTab(int index, Widget child) {
return Offstage(
offstage: _currentTab != index,
child: Navigator(
key: _navigatorKeys[index],
onGenerateRoute: (settings) => MaterialPageRoute(
builder: (_) => child,
settings: settings,
),
),
);
}
}
Key point: Offstage keeps the tab widget alive (preserving scroll position and state) while hiding it. Without it, switching tabs would reset each tab's state.
Option 2: go_router with ShellRoute (recommended)
go_router 6+ supports ShellRoute which wraps multiple child routes in a shared shell (like a BottomNavigationBar) while giving each tab its own navigation stack:
final router = GoRouter(
initialLocation: '/home',
routes: [
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: '/home',
builder: (_, __) => const HomeScreen(),
routes: [
GoRoute(
path: 'article/:id',
builder: (_, state) => ArticleScreen(
id: state.pathParameters['id']!,
),
),
],
),
GoRoute(
path: '/orders',
builder: (_, __) => const OrdersScreen(),
routes: [
GoRoute(
path: ':id',
builder: (_, state) => OrderDetailScreen(
id: state.pathParameters['id']!,
),
),
],
),
GoRoute(
path: '/profile',
builder: (_, __) => const ProfileScreen(),
),
],
),
],
);
The shell widget:
class AppShell extends StatelessWidget {
const AppShell({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
return Scaffold(
body: child,
bottomNavigationBar: BottomNavigationBar(
currentIndex: _indexForLocation(location),
onTap: (i) => context.go(_locationForIndex(i)),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.list), label: 'Orders'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
int _indexForLocation(String location) {
if (location.startsWith('/orders')) return 1;
if (location.startsWith('/profile')) return 2;
return 0;
}
String _locationForIndex(int index) =>
['/home', '/orders', '/profile'][index];
}
StatefulShellRoute for persistent tab state
go_router 7+ added StatefulShellRoute which keeps each tab's navigator state alive across tab switches — the same effect as Offstage in the manual approach, but built-in:
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return AppShell(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(routes: [GoRoute(path: '/home', ...)]),
StatefulShellBranch(routes: [GoRoute(path: '/orders', ...)]),
StatefulShellBranch(routes: [GoRoute(path: '/profile', ...)]),
],
)
Then in the shell:
class AppShell extends StatelessWidget {
const AppShell({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: BottomNavigationBar(
currentIndex: navigationShell.currentIndex,
onTap: navigationShell.goBranch,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.list), label: 'Orders'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
Deep linking with nested navigation
When the app opens with a deep link like /orders/42, go_router navigates to the correct branch and pushes the detail screen automatically. With manual nested navigators you'd have to handle this yourself.
Common pitfalls
Back button exits the app instead of popping the tab stack. Without WillPopScope (or PopScope in Flutter 3.16+), pressing back pops the root navigator. Always intercept back and delegate to the active tab's navigator first.
State loss on tab switch. Without Offstage or StatefulShellRoute, switching tabs destroys and recreates the widget tree. Use either to keep tabs alive.
Pushing routes on the wrong navigator. Inside a nested navigator, Navigator.of(context) returns the nearest navigator — which is the tab's. If you need to push a full-screen modal that covers the bottom bar, use Navigator.of(context, rootNavigator: true).
Mixed use of go_router and imperative Navigator. Mixing context.go() with Navigator.push() creates inconsistent state. Pick one and stick to it.
Sign in to like, dislike, or report.