skip to content

Shell & Nested Routes

ShellRoute wraps child routes in a persistent scaffold, and StatefulShellRoute.indexedStack keeps a separate stack per bottom tab. Interviewers ask which navigator the back button pops.

part ofFlutteroverview, primer and where to startread it →
on this pageshow

explore

questions

4

With go_router, how do you show a full-screen page above the bottom navigation bar from a route nested inside a shell?

level: middleimportance: must knowfreq 52%

answer

  1. which Navigator a page lands on
  2. a GlobalKey for the root Navigator
  3. pass it to GoRouter's navigatorKey
  4. set parentNavigatorKey on the GoRoute
  5. must name an ancestor's key

basics

~20 s

Give GoRouter a GlobalKey<NavigatorState> as navigatorKey and set that same key as the parentNavigatorKey of the nested GoRoute; the page is then placed on the root Navigator above the shell, hiding the bar, while its URL stays nested.

solid answer

~40 s

By default a `GoRoute` under a `ShellRoute` or a `StatefulShellBranch` is placed on the nearest shell's Navigator, so it appears inside the body with the bar still visible. To cover the bar, for example a full-screen photo viewer or a compose-post page in a social app, I create `final rootNavigatorKey = GlobalKey<NavigatorState>()`, pass it to `GoRouter(navigatorKey: rootNavigatorKey)`, and set `parentNavigatorKey: rootNavigatorKey` on that route. go_router then stacks it on the root Navigator, above the whole shell, while its path can stay nested (`/feed/post/:postId/photo`). The key must belong to an ancestor: the `GoRouter`'s key or an enclosing shell's; go_router asserts this. The same property can target an outer shell's Navigator in nested shells. Back or `context.pop()` then returns to the page under the shell.

code

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

final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root');

final GoRouter router = GoRouter(
  navigatorKey: rootNavigatorKey,
  initialLocation: '/feed',
  routes: <RouteBase>[
    StatefulShellRoute.indexedStack(
      builder: (BuildContext context, GoRouterState state, StatefulNavigationShell navigationShell) =>
          Scaffold(body: navigationShell),
      branches: <StatefulShellBranch>[
        StatefulShellBranch(
          routes: <RouteBase>[
            GoRoute(
              path: '/feed',
              builder: (BuildContext context, GoRouterState state) => const Text('Feed'),
              routes: <RouteBase>[
                GoRoute(
                  path: 'post/:postId',
                  builder: (BuildContext context, GoRouterState state) => const Text('Post'),
                  routes: <RouteBase>[
                    GoRoute(
                      path: 'photo',
                      parentNavigatorKey: rootNavigatorKey, // covers the whole shell
                      builder: (BuildContext context, GoRouterState state) => const Text('Photo'),
                    ),
                  ],
                ),
              ],
            ),
          ],
        ),
      ],
    ),
  ],
);

go deeper

for a junior

Recall that a GoRoute under a shell lands on the shell's Navigator unless parentNavigatorKey points it at the root key passed to GoRouter.

for a middle

Explain the default placement rule, the ancestor-only assertion, and how nesting with parentNavigatorKey preserves the stack beneath a full-screen page.

for a senior

Show you design full-screen flows so deep links, back navigation and transitions behave the same as taps, choosing nesting over top-level routes deliberately.

for a principal

Set a navigation convention for the app: which flows cover the chrome, which stay inside tabs, and how keys are owned so teams do not collide.

## Which Navigator shows a page A go_router app with shells has **several Navigators**: the root one created by the `GoRouter`, one per `ShellRoute`, and one per `StatefulShellBranch`. Every matched `GoRoute` is placed on exactly one of them. The default rule is simple: a route goes onto the Navigator of its **nearest shell ancestor**, or onto the root Navigator when it has none. That default is what keeps pages inside the shell's body. It is also why a photo viewer opened from a post in a social app appears with the bottom bar still under it, which is usually wrong for full-screen media, a compose page, or a modal-style flow. ## parentNavigatorKey `GoRoute.parentNavigatorKey` (and the same field on shell routes) overrides the default by naming the Navigator to use: 1. Create a key: `final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();` 2. Hand it to the router: `GoRouter(navigatorKey: rootNavigatorKey, ...)`. Without this, go_router creates its own root key, and your key would refer to nothing. 3. Set `parentNavigatorKey: rootNavigatorKey` on the route that must cover the shell. The page is now stacked on the root Navigator, **above** the shell widget and its bar. Its path is unaffected: it can remain a child route such as `photo` under `/feed/post/:postId`, so the location, deep links and the pages under it stay the same. ## The rules go_router enforces - The key must be the `GoRouter`'s `navigatorKey` or the `navigatorKey` of an **ancestor** `ShellRoute` or branch; anything else fails an assertion reading that the key must refer to an ancestor ShellRoute's navigatorKey or GoRouter's navigatorKey. - Once a route jumps to an outer Navigator, its descendants may only use that Navigator or ones **above** it. - Branch Navigator keys must be unique and must not reuse an ancestor's key. | Route | `parentNavigatorKey` | Shown on | Bar visible | |---|---|---|---| | `/feed` | none | Feed branch Navigator | yes | | `/feed/post/:postId` | none | Feed branch Navigator | yes | | `/feed/post/:postId/photo` | `rootNavigatorKey` | root Navigator | no | | `/compose` (outside the shell) | none | root Navigator | no | ## Choosing between nesting and a top-level route A full-screen page can also be declared **outside** the shell as a top-level route (`/compose`). The difference is what sits beneath it: - With `parentNavigatorKey` on a nested route, the location keeps the parent path, so going to `/feed/post/7/photo` builds Feed, then the post inside the shell, then the photo above everything. Back returns to the post. - As a top-level route, `/compose` has no shell under it on a deep link; back from a cold-start link has nowhere to go inside the app. Nesting with `parentNavigatorKey` is the better fit when the full-screen page belongs to a specific context, such as a photo of a specific post. ## Pushing into shells The same placement logic explains how `context.push` behaves near shells. Pushing a route with no shell over a page inside a shell places it entirely on top; pushing a route of the **same** shell places it inside the shell; pushing a route of a **different** shell places that shell and the page on top of the current screen. ## Nested shells and outer Navigators `parentNavigatorKey` is not limited to the root. In a layout with a `StatefulShellRoute` for the tabs and, inside the Profile branch, a `ShellRoute` that adds a settings side panel, a route deep in the settings shell can name the **Profile branch Navigator's key** to appear over the side panel but still under the bottom bar. The rule is the same at every level: the named Navigator must be an ancestor, and the page is drawn by that Navigator with that Navigator's transition. This is also why the page's entrance animation changes: a route shown on the root Navigator slides or fades over the entire screen, bar included, while the same route on a branch Navigator animates only inside the shell's body. ## Mistakes to avoid - Creating the key but not passing it to `GoRouter(navigatorKey: ...)`. - Using a sibling branch's key, which is not an ancestor and fails the assertion. - Hiding the bar with a flag in the shell builder instead of changing which Navigator shows the page, which leaves the page's transition clipped to the body.

  • With go_router, what happens if a route's parentNavigatorKey names a sibling branch's Navigator?
    go_router's configuration check fails an assertion in debug builds: the key must refer to an ancestor ShellRoute's navigatorKey or the GoRouter's navigatorKey. A sibling branch is not an ancestor, so the route cannot be placed there.
  • With go_router, what is beneath a nested photo route with parentNavigatorKey set to the root key when opened by a deep link?
    go builds the stack from the route tree: the shell with the Feed branch and the post page, then the photo page on the root Navigator above the shell. Back therefore returns to the post inside its tab.
  • With go_router, what does context.push do with a route that belongs to the same shell as the current page?
    The pushed page is placed inside that shell, on its nested Navigator, so the bar stays visible. A route with no shell is placed entirely on top, and a route of a different shell is pushed together with its own shell above the current screen.

saying these in an interview costs you the question

  • Believes parentNavigatorKey changes the route's URL path.
  • Creates a root key but never passes it to GoRouter's navigatorKey.
  • Hides the bar with a flag rather than placing the page on the root Navigator.
  • Points parentNavigatorKey at a sibling branch's Navigator.
  • Thinks every route under a shell is always drawn inside the shell.
open as a page

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

level: juniorimportance: should knowfreq 45%

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.

open as a page

In a go_router app with StatefulShellRoute tabs, which Navigator does the Android back button pop, and why does it not return to the previous tab?

level: seniorimportance: should knowfreq 36%

basics

~20 s

go_router's delegate tries maybePop on the innermost active Navigator first, the current branch's, then works outward to the root; a tab switch is not a page on any stack, so at a branch's root nothing pops and the platform's default back, leaving the app, applies.

open as a page

With go_router, how do you build bottom tabs in a social app so each tab keeps its own navigation history?

level: seniorimportance: should knowfreq 48%

basics

~10 s

Use StatefulShellRoute.indexedStack with one StatefulShellBranch per tab: each branch gets its own Navigator kept alive in an IndexedStack, and the tab bar calls navigationShell.goBranch(index), which restores that branch's last location instead of rebuilding it.

open as a page