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:
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?
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.
sponsor

Build and grow in-app purchases. Glassfy’s Flutter SDK solves all the complexities and edge cases of in-app purchases and subscriptions so you don't have to. Test and build for free today by clicking here.
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:
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 onGoRouter
orGoRoute
, a newNavigator
is used to display any matching sub-routes, instead of placing them on the rootNavigator
.
How does this work in practice?
Let's use this example for reference:
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 theShellRoute
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
andDetailsScreen
are just simple widgets with aScaffold
and a regularAppBar
.
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 astatic
variable inside_ScaffoldWithBottomNavBarState
, or pass it as an argument to theScaffoldWithBottomNavBar
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:
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:
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 correctBeamLocation
for therouteInformation
object - we use an
IndexedStack
that chooses between twoBeamer
widgets based on the_currentIndex
- we add the
BottomNavigationBar
as usual and callsetState
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:
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.
sponsor

Build and grow in-app purchases. Glassfy’s Flutter SDK solves all the complexities and edge cases of in-app purchases and subscriptions so you don't have to. Test and build for free today by clicking here.
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: