skip to content

With go_router, how does ShellRoute keep a bottom navigation bar on screen while the pages under it change?

level: juniorimportance: should knowfreq 45%

answer

  1. a route with no path of its own
  2. builder receives state and a child
  3. the child is a nested Navigator
  4. sub-routes stack on that Navigator
  5. one stack shared by every tab

basics

~20 s

ShellRoute has no path; it wraps its sub-routes in a widget you build, and its builder receives a child that is a nested Navigator showing the matched sub-route, so the scaffold with the navigation bar stays while pages change inside it.

solid answer

~40 s

In go_router, a `ShellRoute` is a route without a path that groups sub-routes under a shared UI shell. Its `builder` has the signature `(BuildContext context, GoRouterState state, Widget child)`, and `child` is a `Navigator` that go_router creates for the matching sub-routes. I return a `Scaffold` whose `body` is `child` and whose `bottomNavigationBar` stays put, so navigating from `/feed` to `/feed/post/7` animates only inside the body. Sub-routes land on the shell's Navigator instead of the root one, identified by the shell's `navigatorKey` (a new `GlobalKey` by default). The limitation is that all its routes share that one Navigator: switching tabs with `context.go('/profile')` replaces the stack, so the Feed tab's history is gone when the user comes back; per-tab history needs `StatefulShellRoute`.

code

dart · 41 lines
dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

final GoRouter router = GoRouter(
  initialLocation: '/feed',
  routes: <RouteBase>[
    ShellRoute(
      builder: (BuildContext context, GoRouterState state, Widget child) {
        final String path = state.uri.path;
        final int index = path.startsWith('/search') ? 1 : path.startsWith('/profile') ? 2 : 0;
        return Scaffold(
          body: child, // the nested Navigator
          bottomNavigationBar: NavigationBar(
            selectedIndex: index,
            onDestinationSelected: (int i) => context.go(const <String>['/feed', '/search', '/profile'][i]),
            destinations: const <Widget>[
              NavigationDestination(icon: Icon(Icons.home), label: 'Feed'),
              NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
              NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
            ],
          ),
        );
      },
      routes: <RouteBase>[
        GoRoute(
          path: '/feed',
          builder: (BuildContext context, GoRouterState state) => const Text('Feed'),
          routes: <RouteBase>[
            GoRoute(
              path: 'post/:postId',
              builder: (BuildContext context, GoRouterState state) =>
                  Text('Post ${state.pathParameters['postId']}'),
            ),
          ],
        ),
        GoRoute(path: '/search', builder: (BuildContext context, GoRouterState state) => const Text('Search')),
        GoRoute(path: '/profile', builder: (BuildContext context, GoRouterState state) => const Text('Profile')),
      ],
    ),
  ],
);

go deeper

for a junior

Recall that ShellRoute has no path, and that its builder receives a child Navigator to place inside a shell such as a Scaffold with a bottom bar.

for a middle

Explain that sub-routes stack on the shell's nested Navigator, how parentNavigatorKey lets a page escape it, and why one Navigator means tab history is lost.

for a senior

Show you pick ShellRoute for chrome whose sections may start fresh, and StatefulShellRoute when tabs must keep state, deriving the selected tab from the location.

for a principal

Weigh the memory and complexity of per-tab stacks against users' expectations for each section, and set a consistent rule across the app's shells.

## What a shell is Many apps have **chrome** that should survive navigation: a bottom navigation bar, a navigation rail, a persistent app bar. With a single `Navigator`, every new page covers the whole screen, chrome included, so the bar would have to be rebuilt on every page and would slide in and out with each transition. go_router's `ShellRoute` solves this by placing a **second `Navigator` inside a widget you control**. The shell widget stays mounted; only the nested Navigator's pages change. ## The ShellRoute API - `ShellRoute` has **no `path`**. It is a grouping node in the route tree; its sub-routes carry the paths (usually absolute, such as `/feed` and `/search`). - `builder: (BuildContext context, GoRouterState state, Widget child)`: `child` is the nested Navigator already configured with the matching sub-routes. Build the shell around it. - `pageBuilder` is the alternative when the shell itself needs a custom `Page`, with the same extra `child` argument. - `navigatorKey`: the `GlobalKey<NavigatorState>` of the nested Navigator. If omitted, go_router creates one; pass your own when other routes must target it. - `observers` and `restorationScopeId` configure the nested Navigator; `notifyRootObserver` (default `true` since go_router 17.0.0) also reports shell navigation to the `GoRouter`'s own observers. ## Where pages go 1. A navigation to `/feed/post/7` matches the shell, then `/feed`, then its child `post/:postId`. 2. go_router builds the shell widget once and hands it the nested Navigator as `child`. 3. The Feed page and the post page are stacked **on the shell's Navigator**, so the post page slides in under the bar, and the AppBar back arrow pops back to the feed inside the body. 4. A route that must cover the bar (a full-screen photo viewer) opts out with `parentNavigatorKey` pointing at the root Navigator's key. ## The limitation: one stack for every tab | Action in a social app | With `ShellRoute` | |---|---| | Open a post from Feed | stacked on the shell Navigator | | Switch to Profile with `context.go('/profile')` | the shell Navigator's stack becomes Profile; the post and Feed pages are removed | | Switch back to Feed | Feed is rebuilt from its route; the open post and the scroll position are gone | Because a `ShellRoute` has exactly **one** nested Navigator, tabs are just different locations on the same stack. That is fine for a persistent app bar or a drawer layout where the user expects each section to start fresh. For bottom tabs whose history must survive switching, go_router provides `StatefulShellRoute`, which builds one Navigator per branch. ## More than one shell Shells compose. A `ShellRoute` can contain other `ShellRoute`s, and a `StatefulShellBranch` can contain a `ShellRoute`, each adding its own nested Navigator. A few properties matter once there is more than one: - `redirect` on a shell route guards every sub-route beneath it, which suits an area such as a settings section that requires a signed-in user. - `metadata` on a shell is inherited by its sub-routes and merged into `GoRouterState.metadata` (go_router 17.5.0). - `observers` attach `NavigatorObserver`s to that shell's Navigator only; with `notifyRootObserver` left at its default `true`, the `GoRouter`'s own observers also hear about navigation inside the shell. - `restorationScopeId` lets the nested Navigator's history survive process death when state restoration is enabled. Each extra shell is one more Navigator for back handling and focus to reason about, so nest only when the chrome genuinely differs. ## Typical mistakes - Putting `Scaffold` with the bar inside every page instead of in the shell builder, which rebuilds and animates the bar with each page. - Forgetting to place `child` in the tree, which leaves a blank body because the nested Navigator is never mounted. - Giving a sub-route of the shell a `parentNavigatorKey` that is not an ancestor's key; go_router asserts that it must refer to an ancestor ShellRoute's `navigatorKey` or the `GoRouter`'s `navigatorKey`. - Expecting tab history to survive, then patching it by caching locations by hand instead of switching to `StatefulShellRoute`. ## Where the bar's current tab comes from The shell builder receives the `GoRouterState`, so the selected index can be derived from `state.uri.path` or `state.matchedLocation` (for example, a path starting with `/profile` selects the third item). Deriving it from the location, rather than keeping a separate index in a widget, keeps the bar correct after deep links and browser navigation.

  • With go_router, what does a ShellRoute builder's child parameter contain?
    A `Navigator` that go_router builds for the shell's matching sub-routes, keyed by the shell's `navigatorKey`. The builder places it where page content should appear, typically as the `body` of a `Scaffold`; if it is left out of the tree, no sub-route page is shown.
  • With go_router, why does switching tabs under a ShellRoute lose the previous tab's open post?
    A `ShellRoute` has one nested Navigator for all its sub-routes. `context.go('/profile')` replaces that Navigator's stack with the Profile route, so the Feed and post pages are removed; returning to `/feed` rebuilds Feed from scratch. `StatefulShellRoute` keeps one Navigator per branch instead.
  • With go_router, how does the shell know which tab to highlight after a deep link?
    The shell builder receives the `GoRouterState` for the match, so it can derive the selected index from `state.uri.path` or `state.matchedLocation`. Deriving it from the location instead of a separately stored index keeps the bar correct after deep links and browser back.

A ShellRoute is a picture frame with one canvas: you can repaint the canvas as often as you like and the frame stays on the wall, but there is only one canvas, so painting the Profile picture wipes out the Feed picture that was there before.

saying these in an interview costs you the question

  • Believes ShellRoute takes a path that prefixes every sub-route.
  • Puts the navigation bar in every page instead of the shell builder.
  • Expects ShellRoute to remember each tab's stack when switching tabs.
  • Thinks sub-routes of a ShellRoute are placed on the root Navigator.
  • Leaves the builder's child parameter out of the widget tree.