DEV Community

Twilight
Twilight

Posted on

Mastering Nested Navigation in Flutter with `go_router` and a Bottom Nav Bar

Managing multiple tabs, each with its own navigation history, can feel daunting. In this post, we’ll explore how to use go_router to set up nested navigation in Flutter—complete with a persistent BottomNavigationBar—while still being able to navigate into detail screens without losing the bottom bar.


1. Why Nested Navigation?

In many apps (e.g., music or podcast apps), each bottom-tab (Home, Discover, Library, Profile, etc.) needs to maintain its own navigation history. When a user switches tabs, they should return to the previous screen within that tab, not reset to the root screen.

Key points:

  • Preserve each tab’s state when switching.
  • Allow deeper routes (Detail screens) inside each tab.
  • Keep the bottom bar visible even on detail pages.

2. Setting Up a StatefulShellRoute.indexedStack

go_router offers a StatefulShellRoute that behaves like an IndexedStack. Each tab corresponds to a StatefulShellBranch:

final router = GoRouter(
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, navShell) => Scaffold(
        body: navShell,
        bottomNavigationBar: BottomNavigationBar(...), 
      ),
      branches: [
        // Branch 1: Home
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/home',
              builder: (context, state) => HomePage(),
              routes: [
                GoRoute(
                  path: 'podcast/:podcastId',
                  builder: (context, state) => PodcastDetailPage(),
                ),
              ],
            ),
          ],
        ),
        // Branch 2: Discover
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/discover',
              builder: (context, state) => DiscoverPage(),
              routes: [
                GoRoute(
                  path: 'podcast/:podcastId',
                  builder: (context, state) => PodcastDetailPage(),
                ),
              ],
            ),
          ],
        ),
        // ...Other branches
      ],
    ),
  ],
);
Enter fullscreen mode Exit fullscreen mode
  • Each branch has its own sub-routes.
  • Navigating to '/home/podcast/123' will push a detail page within the Home branch, so your bottom bar remains.

3. Making Detail Routes “Shared” Without Repetition

If you have the same detail screen (e.g., PodcastDetailPage) in multiple tabs, consider a helper function to generate shared routes:

List<GoRoute> buildPodcastRoutes(String branchName) => [
  GoRoute(
    name: '${branchName}PodcastDetail',
    path: 'podcast/:podcastId',
    builder: (context, state) => PodcastDetailPage(),
  ),
];

// Then in each branch:
GoRoute(
  path: '/home',
  routes: [
    ...buildPodcastRoutes('home'),
  ],
  builder: (context, state) => HomePage(),
),
Enter fullscreen mode Exit fullscreen mode

This way, you “define once,” but attach them to each branch. Each detail route name is unique (homePodcastDetail, discoverPodcastDetail, etc.), allowing goNamed without clashing.


4. Navigating to Detail from Shared Widgets

You might have shared widgets (like PodcastCard) that shouldn’t hard-code which tab’s detail route to use. One clean approach is Inversion of Control:

  • Parent (e.g., HomePage) passes a callback to PodcastCard.
  • PodcastCard just calls onTap?.call() without knowing the route name.
class PodcastCard extends StatelessWidget {
  final String podcastId;
  final VoidCallback onTap; 
  // ...

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: ...
    );
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return PodcastCard(
      podcastId: '123',
      onTap: () => context.goNamed('homePodcastDetail', params: {'podcastId': '123'}),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This keeps routing logic in the page (which knows it’s the Home tab), not in the shared widget.


5. Best Practices at a Glance

  1. Use StatefulShellRoute.indexedStack for bottom-tabs, ensuring each tab has a separate route branch and persistent state.
  2. Keep detail routes as children of each tab’s branch, so the bottom bar never disappears.
  3. Avoid repeating route definitions with helper methods that generate shared detail routes for each branch.
  4. Leverage named routes (goNamed / pushNamed) to avoid hard-coding paths.
  5. Separate your UI from navigation logic by passing callbacks or using a provider/DI approach.

6. Wrapping Up

With go_router, setting up nested navigation for multiple tabs is remarkably straightforward—once you know how to structure your routes! The StatefulShellRoute keeps each tab’s history intact, and sub-routes let you push detail pages without losing the bottom bar. Combine these techniques with consistent naming for a scalable, clean navigation system in Flutter.

Happy routing!

Top comments (0)