Skip to content
Blog

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/account or /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

PatternImplementationBenefit
Tab Re-tap BehaviornavigationShell.goBranch(index, initialLocation: true)Pops sub-pages back to branch root when tab is tapped twice
Deep Link SyncPaths mapped directly inside branchesBrowsing to /feed/detail/99 switches to Tab 0 and opens detail
Adaptive LayoutsSwitch between NavigationBar and NavigationRailFlawless UX across phones, foldables, tablets, and desktop
State PreservationindexedStack keeps widgets in element treeScroll 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.