Flutter Bottom Navigation Bar with Nested Routes: GoRouter vs Beamer Comparison

Source code on GitHub

One of the most popular UX patterns on mobile is to show a bottom navigation bar with tabs.

With this pattern, all the tabs are within the "thumb zone" where they can be easily reached:

Thumb zone on mobile devices
Thumb zone on mobile devices. Credit: Rosenfield Media

But how can we implement bottom navigation in a Flutter app?

The usual answer is that there’s a widget for that: BottomNavigationBar.

But once we dive a bit deeper, some other questions arise:

  • How to show different pages when we switch between tabs?
  • How to perform nested navigation and push additional pages inside each tab?
  • How to preserve the navigation state of each tab?

In other words, how can we implement this behavior?

Nested stateful navigation example. Note how the counter value is preserved when switching between tabs.
Nested stateful navigation example. Note how the counter value is preserved when switching between tabs.

I’ve already tackled these questions in a previous article, showing how to implement nested navigation with a combination of Stack, Navigator, and Offstage widgets:

However, my previous solution had some limitations and caused too many widget rebuilds.

And since it was built with the Navigator 1.0 APIs, it didn't support deep linking and navigation by URL.

GoRouter vs Beamer

The new Router API (also known as Navigator 2.0) was introduced to support deep linking, URL navigation, and additional use cases.

In turn, this led to packages such as GoRouter and Beamer, that provide simple yet powerful routing APIs to meet the needs of all Flutter apps across different platforms.

So in this article, I'll show you how to implement nested navigation using both GoRouter (by exploring the new ShellRoute API) and Beamer, offering a fair comparison between these two packages for this common use case.

Nested navigation is also supported by the AutoRoute package, but we won't cover it here. Refer to the documentation for more details about nested navigation with AutoRoute.

We'll start with a simple example showing the basics of nested navigation, so we can better understand the purpose of the new ShellRoute class.

Then, we'll explore the limitations of the current GoRouter APIs, along with a proposed solution for overcoming them.

And finally, we'll learn how to build the same example app using Beamer (full source code for both examples is available here).

Ready? Let's go!

Fully responsive apps require some complex routing logic to handle different navigation patterns for different screen sizes. For example, we may use bottom navigation on mobile and side navigation on wider screens (using a drawer or a navigation rail). For simplicity, this article will focus on mobile only, and I may tackle more complex navigation scenarios in the future.

Nested Navigation Requirements

Before we dive into the code, let's take another look at this:

Nested navigation example. Note how the counter value is preserved when switching tabs.
Nested navigation example. Note how the counter value is preserved when switching tabs.

To build this simple app, two separate issues need to be addressed:

  • Support multiple navigation stacks: this is offered by the new ShellRoute API (which was added as part of issue #99126).
  • Preserve the state of routes: this is not yet implemented as of GoRouter 5.0 (see issue #99124 and the corresponding PR #2650), but works just fine if we use Beamer along with an IndexedStack widget.

So we'll keep this in mind as we make progress.

GoRouter 5.0 does not have an API for preserving the state of the routes when we switch between tabs. Version 5.1 will address this with an updated ShellRoute API (see issue #99124 and PR #2650 for more details), and I'll update this article when this has been resolved. If your app needs nested navigation in production today, consider using Beamer instead of GoRouter.

GoRouter Implementation with ShellRoute

According to the documentation, ShellRoute is:

A route that displays a UI shell around the matching child route.

It also says this:

When a ShellRoute is added to the list of routes on GoRouter or GoRoute, a new Navigator is used to display any matching sub-routes, instead of placing them on the root Navigator.

How does this work in practice?

Let's use this example for reference:

BottomNavigationBar example with two tabs
BottomNavigationBar example with two tabs

As we can see, the detail page covers Screen A, but not the tabs at the bottom (the UI shell).

This route hierarchy can be represented like this:

GoRouter └─ ShellRoute ├─ GoRoute('/a') ├─ GoRoute('details') └─ GoRoute('/b') └─ GoRoute('details')

And implemented like this using the GoRouter APIs:

// private navigators final _rootNavigatorKey = GlobalKey<NavigatorState>(); final _shellNavigatorKey = GlobalKey<NavigatorState>(); // the one and only GoRouter instance final goRouter = GoRouter( initialLocation: '/a', navigatorKey: _rootNavigatorKey, routes: [ ShellRoute( navigatorKey: _shellNavigatorKey, builder: (context, state, child) { return ScaffoldWithBottomNavBar(child: child); }, routes: [ GoRoute( path: '/a', pageBuilder: (context, state) => NoTransitionPage( child: const RootScreen(label: 'A', detailsPath: '/a/details'), ), routes: [ GoRoute( path: 'details', builder: (context, state) => const DetailsScreen(label: 'A'), ), ], ), GoRoute( path: '/b', pageBuilder: (context, state) => NoTransitionPage( child: const RootScreen(label: 'B', detailsPath: '/b/details'), ), routes: [ GoRoute( path: 'details', builder: (context, state) => const DetailsScreen(label: 'B'), ), ], ), ], ), ], ); // use like this: // MaterialApp.router(routerConfig: goRouter, ...)

A few notes:

  • The main GoRouter uses a _rootNavigatorKey, but the ShellRoute uses a _shellNavigatorKey. to guarantee that sub-routes appear inside the UI shell (rather than covering it).
  • We use a NoTransitionPage inside the /a and /b routes to prevent unintended animations when switching between tabs (this is the default behavior on popular iOS apps).
  • RootScreen and DetailsScreen are just simple widgets with a Scaffold and a regular AppBar.

What's more interesting is the builder argument of the ShellRoute. 👇

The application shell: ScaffoldWithBottomNavBar

The builder argument of the ShellRoute takes an additional child argument that we can pass to the ScaffoldWithBottomNavBar class:

ShellRoute( navigatorKey: _shellNavigatorKey, builder: (context, state, child) { // a widget used to build the UI shell around the child widget return ScaffoldWithBottomNavBar(child: child); }, routes: [...], )

Here's how we may implement this class:

class ScaffoldWithBottomNavBar extends StatefulWidget { const ScaffoldWithBottomNavBar({Key? key, required this.child}) : super(key: key); final Widget child; @override State<ScaffoldWithBottomNavBar> createState() => _ScaffoldWithBottomNavBarState(); } class _ScaffoldWithBottomNavBarState extends State<ScaffoldWithBottomNavBar> { @override Widget build(BuildContext context) { return Scaffold( body: widget.child, bottomNavigationBar: BottomNavigationBar( currentIndex: /* what goes here? */, items: /* what goes here? */, onTap: /* what goes here? */, ), ); } }

As we can see, widget.child is passed to the body of the Scaffold.

But what does this widget represent?

Well, our ShellRoute has two sub-routes (/a and /b) that return a RootScreen.

This means that widget.child is the widget that is returned by the currently selected sub-route (RootScreen from the initial route or DetailScreen if we go to the detail page).

But what arguments should we pass to the BottomNavigationBar?

Defining the Tabs

To complete our example, let's define a new BottomNavigationBarItem subclass that contains an additional initialLocation property:

/// Representation of a tab item in the [ScaffoldWithBottomNavBar] class ScaffoldWithNavBarTabItem extends BottomNavigationBarItem { const ScaffoldWithNavBarTabItem( {required this.initialLocation, required Widget icon, String? label}) : super(icon: icon, label: label); /// The initial location/path final String initialLocation; }

Then, we can define our tabs like this:

const tabs = [ ScaffoldWithNavBarTabItem( initialLocation: '/a', icon: Icon(Icons.home), label: 'Section A', ), ScaffoldWithNavBarTabItem( initialLocation: '/b', icon: Icon(Icons.settings), label: 'Section B', ), ];

In this example, I've defined tabs as a global variable, but you could create it as a static variable inside _ScaffoldWithBottomNavBarState, or pass it as an argument to the ScaffoldWithBottomNavBar widget.

And now, we can update the _ScaffoldWithBottomNavBarState class:

class _ScaffoldWithBottomNavBarState extends State<ScaffoldWithBottomNavBar> { // getter that computes the current index from the current location, // using the helper method below int get _currentIndex => _locationToTabIndex(GoRouter.of(context).location); int _locationToTabIndex(String location) { final index = tabs.indexWhere((t) => location.startsWith(t.initialLocation)); // if index not found (-1), return 0 return index < 0 ? 0 : index; } // callback used to navigate to the desired tab void _onItemTapped(BuildContext context, int tabIndex) { if (tabIndex != _currentIndex) { // go to the initial location of the selected tab (by index) context.go(tabs[tabIndex].initialLocation); } } @override Widget build(BuildContext context) { return Scaffold( body: widget.child, bottomNavigationBar: BottomNavigationBar( currentIndex: _currentIndex, items: tabs, onTap: (index) => _onItemTapped(context, index), ), ); } }

This is how the code above works:

  • We use the _locationToTabIndex method to compute the current tab index from a given GoRouter location. For example, both /a and /a/details will resolve to index 0, while both /b and /b/details will resolve to index 1.
  • The code inside _onItemTapped tells GoRouter to navigate to the initial location of the tab that is identified by the selected index.

Let's see what happens if we try to run the app at this stage:

Nested navigation example. Note how we lose track of the nested route when switching tabs.
Nested navigation example. Note how we lose track of the nested route when switching tabs.

We've made some progress:

  • We can switch between tabs
  • If we open the DetailsScreen, this is correctly nested and doesn't cover the bottom navigation

However, we still have one major issue. 👇

The navigation state of each tab is not preserved

The main reason for this is on this line:

context.go(tabs[tabIndex].initialLocation);

The problem here is that we always navigate to the initialLocation of the chosen tab (which will route to the RootScreen).

But how can we tell GoRouter to remember the previously opened sub-route?

Unfortunately, this is not supported as of GoRouter 5.0, but it is planned for version 5.1 (see this issue and the corresponding PR).

But we need proper nested navigation support! Welp!

One way to get this working is to create a ShellRoute subclass with separate navigators for each tab. This way, we can keep track of all the pages inside each tab and tell GoRouter to go to the current location of the selected tab.

Currently, this approach is implemented in the example app included here:

And in fact, the working example I presented is based on this solution:

Nested stateful navigation example. Note how the counter value is preserved when switching tabs.
Nested stateful navigation example. Note how the counter value is preserved when switching tabs.

At the time of writing, the PR is still being discussed and the implementation details may change, so I'm not going to cover them here. When nested stateful navigation is added to GoRouter, I'll update this article.

But for now, let's take a look at Beamer. 👇

Beamer Implementation

Since I have never covered the Beamer package before, let me start with the basics.

After installing the package, we can configure the root widget of our app as follows:

class MyApp extends StatelessWidget { MyApp({super.key}); final routerDelegate = BeamerDelegate( initialPath: '/a', locationBuilder: RoutesLocationBuilder( routes: { '*': (context, state, data) => const ScaffoldWithBottomNavBar(), }, ), ); @override Widget build(BuildContext context) { return MaterialApp.router( routerDelegate: routerDelegate, routeInformationParser: BeamerParser(), backButtonDispatcher: BeamerBackButtonDispatcher( delegate: routerDelegate, ), ); } }

In this case, we need to declare a BeamerDelegate along with a single route that matches the '*' path and returns a ScaffoldWithBottomNavBar widget.

Adding the BeamLocation subclasses

If you're coming from GoRouter, you're used to declaring the entire route hierarchy with a single GoRouter instance.

But with Beamer, you need to create one or more BeamLocation subclasses.

According to the documentation, a BeamLocation represents a stack of one or more pages and has three important roles:

  • know which URIs it can handle: pathPatterns
  • know how to build a stack of pages: buildPages
  • keep a state that provides a link between the first two

Since each tab in our example app has two pages (routes), we can create this class:

class ALocation extends BeamLocation<BeamState> { ALocation(super.routeInformation); @override List<String> get pathPatterns => ['/*']; @override List<BeamPage> buildPages(BuildContext context, BeamState state) => [ const BeamPage( key: ValueKey('a'), title: 'Tab A', type: BeamPageType.noTransition, child: RootScreen(label: 'A', detailsPath: '/a/details'), ), if (state.uri.pathSegments.length == 2) const BeamPage( key: ValueKey('a/details'), title: 'Details A', child: DetailsScreen(label: 'A'), ), ]; }

Note how the buildPages method uses some conditional logic to decide if the second page should be added, based on how many segments are in the state.uri property.

Since we need two tabs, we can also create a BLocation class that is nearly identical to the previous one:

class BLocation extends BeamLocation<BeamState> { BLocation(super.routeInformation); @override List<String> get pathPatterns => ['/*']; @override List<BeamPage> buildPages(BuildContext context, BeamState state) => [ const BeamPage( key: ValueKey('b'), title: 'Tab B', type: BeamPageType.noTransition, child: RootScreen(label: 'B', detailsPath: '/b/details'), ), if (state.uri.pathSegments.length == 2) const BeamPage( key: ValueKey('b/details'), title: 'Details B', child: DetailsScreen(label: 'B'), ), ]; }

The ScaffoldWithBottomNavBar widget

Just like in the GoRouter example, we can create a ScaffoldWithBottomNavBar class to show the BottomNavigationBar and coordinate between the tabs.

Here is the full implementation: 👇

class ScaffoldWithBottomNavBar extends StatefulWidget { const ScaffoldWithBottomNavBar({super.key}); @override State<ScaffoldWithBottomNavBar> createState() => _ScaffoldWithBottomNavBarState(); } class _ScaffoldWithBottomNavBarState extends State<ScaffoldWithBottomNavBar> { // keep track of the currently selected index late int _currentIndex; // create two nested delegates final _routerDelegates = [ BeamerDelegate( initialPath: '/a', locationBuilder: (routeInformation, _) { if (routeInformation.location!.contains('/a')) { return ALocation(routeInformation); } return NotFound(path: routeInformation.location!); }, ), BeamerDelegate( initialPath: '/b', locationBuilder: (routeInformation, _) { if (routeInformation.location!.contains('/b')) { return BLocation(routeInformation); } return NotFound(path: routeInformation.location!); }, ), ]; // update the _currentIndex if necessary @override void didChangeDependencies() { super.didChangeDependencies(); final uriString = Beamer.of(context).configuration.location!; _currentIndex = uriString.contains('/a') ? 0 : 1; } @override Widget build(BuildContext context) { return Scaffold( // use an IndexedStack to choose which child to show body: IndexedStack( index: _currentIndex, children: [ // use Beamer widgets as children Beamer( routerDelegate: _routerDelegates[0], ), Beamer( routerDelegate: _routerDelegates[1], ), ], ), // the usual BottomNavigationBar bottomNavigationBar: BottomNavigationBar( currentIndex: _currentIndex, items: const [ BottomNavigationBarItem(label: 'Section A', icon: Icon(Icons.home)), BottomNavigationBarItem( label: 'Section B', icon: Icon(Icons.settings)), ], onTap: (index) { if (index != _currentIndex) { setState(() => _currentIndex = index); _routerDelegates[_currentIndex].update(rebuild: false); } }, ), ); } }

Here are the most important points:

  • we keep track of the current index with a state variable
  • we create two new BeamerDelegate objects that return the correct BeamLocation for the routeInformation object
  • we use an IndexedStack that chooses between two Beamer widgets based on the _currentIndex
  • we add the BottomNavigationBar as usual and call setState to update the current index when a new tab is selected

And if we run the app at this stage, we can see that everything works as intended:

Nested stateful navigation example. Note how the counter value is preserved when switching tabs.
Nested stateful navigation example. Note how the counter value is preserved when switching tabs.

GoRouter vs Beamer: Verdict

As it turns out, supporting nested navigation with deep linking is not a trivial matter.

To this day, GoRouter still doesn't offer a suitable API for this use case - though the planned changes for version 5.1 look very promising.

For now, Beamer is the winner as makes it relatively easy to implement nested navigation.

API Design

From an API design standpoint, Beamer lets us create Beamer and BeamerDelegate instances anywhere in the widget tree. As we can see from the examples in the official Beamer documentation, we can use it to create arbitrarily complex navigation scenarios with ease.

The GoRouter APIs take a different approach, by declaring the entire route hierarchy inside a single GoRouter instance and use ShellRoute for nested navigation.

Both Beamer and GoRouter can be used to implement nested navigation with an IndexedStack and multiple navigators, meaning that all the pages inside each tab are kept in memory at all times. An alternative approach would be to use the Flutter state restoration APIs and enable "persistent" navigation stacks. Read this comment for more details.

Examples and Documentation

When evaluating packages for production use, good examples and documentation are just as crucial as API capabilities.

And in this respect, Beamer also wins by offering an extensive list of examples covering use cases of various complexity.

In fact, I was able to take the existing Bottom Navigation with Multiple Beamers Example, and recreate the example app shown here in less than one hour.

On the other hand, the GoRouter examples still lack many of the use cases that are needed by non-trivial apps.

GoRouter used to have an excellent documentation site, which is still accessible here. But this has not been updated since the Flutter team adopted the package.

Conclusion

If you need to implement nested navigation in your apps today, I recommend using the Beamer package.

But just to be clear: this article didn't include a complete comparison of every feature and API offered by GoRouter and Beamer.

So it's up to you to evaluate the two based on the specific requirements of your apps.

And if you already have a GoRouter-based app that needs nested navigation, you won't have wait long since there's already an open PR for nested stateful navigation.

I plan to to cover some of the new GoRouter APIs in my upcoming articles.

And to learn how to use it to build a real-world app, check out my latest course. 👇

New Flutter Course Now Available

I launched a brand new course that covers GoRouter in great depth, along with other important topics like state management, app architecture, testing, and much more:

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass - Full Course

Flutter Animations Masterclass - Full Course

Master Flutter animations and build a completely custom habit tracking application.