Navigation & Routing

Nested Routes and ShellRoute in GoRouter

16 min Lesson 11 of 14

Nested Routes and ShellRoute in GoRouter

As Flutter applications grow, you often need a persistent shell — a bottom navigation bar, a drawer, or a side rail — that stays mounted on screen while the user navigates between different sub-pages. GoRouter's ShellRoute is the purpose-built mechanism for this pattern. Unlike a plain GoRoute that replaces the entire page stack, a ShellRoute wraps its child routes inside a shared Widget builder, so the shell remains alive and the active child route is swapped in place.

Why ShellRoute Exists

Without ShellRoute, every deep-link navigation destroys and recreates the scaffold that contains your BottomNavigationBar, causing a visible flash and losing the scroll position or form state of each tab. ShellRoute solves this by mounting the shell widget once and letting child routes render inside a Navigator widget that lives inside the shell body. The result is identical to what Flutter's classic IndexedStack pattern produces, but it works with GoRouter's declarative URL-based navigation.

Note: ShellRoute was introduced in go_router 6.x. It is the recommended replacement for building tab-based or drawer-based layouts. Always prefer it over manually managing an IndexedStack when you are already using GoRouter.

Anatomy of a ShellRoute

A ShellRoute requires a builder (or pageBuilder) that receives a BuildContext, a GoRouterState, and a Widget child. The child is the currently active nested route rendered by GoRouter. You embed it inside your shell scaffold wherever the content should appear.

Minimal ShellRoute Setup

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

final GoRouter appRouter = GoRouter(
  initialLocation: '/home',
  routes: [
    ShellRoute(
      builder: (BuildContext context, GoRouterState state, Widget child) {
        return ScaffoldWithNavBar(child: child);
      },
      routes: [
        GoRoute(
          path: '/home',
          builder: (context, state) => const HomeScreen(),
        ),
        GoRoute(
          path: '/search',
          builder: (context, state) => const SearchScreen(),
        ),
        GoRoute(
          path: '/profile',
          builder: (context, state) => const ProfileScreen(),
        ),
      ],
    ),
  ],
);

class ScaffoldWithNavBar extends StatelessWidget {
  final Widget child;
  const ScaffoldWithNavBar({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _selectedIndex(context),
        onTap: (index) => _onTap(context, index),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    );
  }

  int _selectedIndex(BuildContext context) {
    final String location = GoRouterState.of(context).uri.toString();
    if (location.startsWith('/search')) return 1;
    if (location.startsWith('/profile')) return 2;
    return 0;
  }

  void _onTap(BuildContext context, int index) {
    switch (index) {
      case 0: context.go('/home'); break;
      case 1: context.go('/search'); break;
      case 2: context.go('/profile'); break;
    }
  }
}
Tip: Use GoRouterState.of(context).uri.toString() (GoRouter 10+) or GoRouterState.of(context).location (older versions) inside the shell to determine the active tab. This keeps the shell stateless — no setState required for tab highlighting.

Deep Nesting: Child Routes Inside a ShellRoute

You can nest GoRoutes multiple levels deep under a ShellRoute. For example, a /profile tab might have a sub-page /profile/edit that still shows the bottom nav. Add it as a child of the /profile route:

Nested Child Routes Under a ShellRoute

ShellRoute(
  builder: (context, state, child) => ScaffoldWithNavBar(child: child),
  routes: [
    GoRoute(
      path: '/home',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/profile',
      builder: (context, state) => const ProfileScreen(),
      routes: [
        // This sub-route is still inside the shell — nav bar stays visible
        GoRoute(
          path: 'edit',  // resolves to /profile/edit
          builder: (context, state) => const EditProfileScreen(),
        ),
      ],
    ),
  ],
),

// Outside the ShellRoute — no nav bar (e.g. full-screen detail)
GoRoute(
  path: '/product/:id',
  builder: (context, state) {
    final String id = state.pathParameters['id']!;
    return ProductDetailScreen(productId: id);
  },
),

Preserving State Across Tabs

By default, navigating away from a tab with context.go() disposes the previous tab's widget. To preserve each tab's scroll position and internal state, use StatefulShellRoute with StatefulShellBranch (introduced in GoRouter 9.x). Each branch gets its own Navigator, keeping its widget tree alive in memory.

Note: StatefulShellRoute.indexedStack is the most common constructor. It mirrors the classic IndexedStack pattern but integrates fully with GoRouter's URL system.

StatefulShellRoute Example

Use StatefulShellRoute.indexedStack when each tab must retain its own navigation stack and widget state independently:

StatefulShellRoute.indexedStack

final GoRouter appRouter = GoRouter(
  initialLocation: '/feed',
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) {
        return ScaffoldWithNavBar(navigationShell: navigationShell);
      },
      branches: [
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/feed',
              builder: (context, state) => const FeedScreen(),
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/explore',
              builder: (context, state) => const ExploreScreen(),
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/inbox',
              builder: (context, state) => const InboxScreen(),
            ),
            // Nested within the inbox branch
          ],
        ),
      ],
    ),
  ],
);

class ScaffoldWithNavBar extends StatelessWidget {
  final StatefulNavigationShell navigationShell;
  const ScaffoldWithNavBar({super.key, required this.navigationShell});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,  // renders the active branch
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: navigationShell.currentIndex,
        onTap: (index) => navigationShell.goBranch(
          index,
          initialLocation: index == navigationShell.currentIndex,
        ),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.rss_feed), label: 'Feed'),
          BottomNavigationBarItem(icon: Icon(Icons.explore), label: 'Explore'),
          BottomNavigationBarItem(icon: Icon(Icons.inbox), label: 'Inbox'),
        ],
      ),
    );
  }
}
Warning: Do not place a ShellRoute inside another ShellRoute unless you have a specific reason for nested shells (e.g., a sub-section with its own tab bar). Deeply nested shells make URL paths hard to reason about and can create confusing back-button behaviour.

ShellRoute vs StatefulShellRoute — Quick Comparison

  • ShellRoute — Simple shell wrapper. Child routes share the same Navigator. Navigating away disposes the previous child. Use when tabs do not need independent state.
  • StatefulShellRoute — Each branch has its own Navigator. Switching tabs preserves state. Use when you need tab state preservation (scroll positions, loaded data, etc.).

Summary

ShellRoute is the cornerstone of persistent-shell navigation in GoRouter. It lets you mount a scaffold once and swap child routes inside it, enabling bottom navigation bars and drawers without page rebuilds. For applications that need per-tab state preservation, upgrade to StatefulShellRoute.indexedStack. Both approaches keep your route tree declarative, URL-driven, and deep-link compatible — the hallmarks of a well-structured Flutter navigation architecture.

Tip: Always define routes that should appear outside the shell (full-screen modals, splash screens, onboarding) as siblings of the ShellRoute at the top level of your GoRouter.routes list, not as children of it.