Routes & Modules
Shell Routes

Shell Routes

Shell routes provide a shared layout (navigation bar, sidebar, tabs) that wraps child routes.

ShellModularRoute

The basic shell route wraps child routes with a persistent layout. All children share the same navigator.

class DashboardModule extends Module {
  @override
  List<ModularRoute> get routes => [
    ShellModularRoute(
      builder: (context, state, child) => DashboardLayout(child: child),
      routes: [
        ModuleRoute('/home', module: HomeModule()),
        ModuleRoute('/settings', module: SettingsModule()),
      ],
    ),
  ];
}

Do not place a ChildRoute('/') inside ShellModularRoute. Children must define concrete paths (e.g. /home, /settings).

Layout Example

class DashboardLayout extends StatelessWidget {
  final Widget child;
  const DashboardLayout({super.key, required this.child});
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Dashboard')),
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'),
        ],
        onTap: (index) {
          if (index == 0) context.go('/home');
          if (index == 1) context.go('/settings');
        },
      ),
    );
  }
}

StatefulShellModularRoute

For persistent state across tabs. Each branch gets its own navigator backed by an IndexedStack, so switching tabs preserves scroll position, form state, and navigation history.

This wraps GoRouter's StatefulShellRoute.indexedStack.

Basic Example

class TabsModule extends Module {
  @override
  List<ModularRoute> get routes => [
    StatefulShellModularRoute(
      builder: (context, state, navigationShell) => AppShell(
        navigationShell: navigationShell,
      ),
      branches: [
        ModularBranch(
          routes: [
            ChildRoute('/home', child: (_, __) => HomePage()),
          ],
        ),
        ModularBranch(
          routes: [
            ChildRoute('/search', child: (_, __) => SearchPage()),
          ],
        ),
        ModularBranch(
          routes: [
            ChildRoute('/profile', child: (_, __) => ProfilePage()),
          ],
        ),
      ],
    ),
  ];
}

When using StatefulShellModularRoute inside a ModuleRoute, navigating to the module path (e.g. /tabs) automatically redirects to the first branch.

Branches with Modules

Each branch can contain a full module with its own dependencies and routes. Branch module binds are registered lazily on first visit and disposed when the shell exits:

StatefulShellModularRoute(
  builder: (context, state, navigationShell) => AppShell(
    navigationShell: navigationShell,
  ),
  branches: [
    // Branch with a full module (binds + routes)
    ModularBranch(module: HomeBranchModule()),
 
    // Branch with direct routes
    ModularBranch(
      routes: [
        ChildRoute('/search', child: (_, __) => SearchPage()),
      ],
    ),
 
    // Branch with a nested module route
    ModularBranch(
      routes: [
        ModuleRoute('/settings', module: SettingsModule()),
      ],
    ),
  ],
)
class HomeBranchModule extends Module {
  @override
  FutureBinds binds(Injector i) {
    i.addSingleton<HomeController>((i) => HomeController());
  }
 
  @override
  List<ModularRoute> get routes => [
    ChildRoute('/home', child: (_, __) => HomePage()),
  ];
}

Dependency Injection Lifecycle

EventWhat happens
Shell enters navigation treeShell module binds are registered
First visit to a branchBranch module binds are registered (lazy)
Switch between branchesNothing — all branches stay alive in IndexedStack
Shell exits navigation treeAll branch modules are disposed, then the shell module

Because IndexedStack keeps all branches alive, branch modules are not disposed when switching tabs. This is by design — state persists across tab switches.

Layout with StatefulNavigationShell

The builder receives a StatefulNavigationShell that manages tab switching:

class AppShell extends StatelessWidget {
  final StatefulNavigationShell navigationShell;
 
  const AppShell({super.key, required this.navigationShell});
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: NavigationBar(
        selectedIndex: navigationShell.currentIndex,
        onDestinationSelected: (index) {
          navigationShell.goBranch(
            index,
            initialLocation: index == navigationShell.currentIndex,
          );
        },
        destinations: const [
          NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
          NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
          NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    );
  }
}

ModularBranch Parameters

ParameterTypeDescription
routesList<ModularRoute>?Direct routes for this branch
moduleModule?A module whose routes and binds will be used
navigatorKeyGlobalKey<NavigatorState>?Custom navigator key for this branch
initialLocationString?Initial route when switching to this branch
restorationScopeIdString?Restoration scope for state restoration
observersList<NavigatorObserver>?Navigation observers for this branch

Each branch must have either routes or module, not both.

StatefulShellModularRoute Parameters

ParameterTypeDescription
branchesList<ModularBranch>Required. The branches for the shell
builderWidget Function(context, state, navigationShell)?Builds the shell layout
pageBuilderPage Function(context, state, navigationShell)?Custom page builder
redirectFutureOr<String?> Function(context, state)?Redirect logic
parentNavigatorKeyGlobalKey<NavigatorState>?Parent navigator key
restorationScopeIdString?Restoration scope
shellKeyGlobalKey<StatefulNavigationShellState>?Key to access shell state

When to Use Which

FeatureShellModularRouteStatefulShellModularRoute
Shared layoutYesYes
Preserves tab stateNoYes
Own navigator per tabNoYes
Back button per tabNoYes
Branch-level DINoYes
Use caseSimple layouts, sidebarsBottom navigation, tab bars

Best Practices

  • Do not place ChildRoute('/') inside a shell route. Use concrete paths like /home.
  • Keep the shell builder minimal: layout and navigation only. Push business logic into child modules.
  • Use ModularBranch(module: ...) to keep branch features isolated and testable.
  • Use StatefulShellModularRoute when you need state to persist across tab switches.
  • Navigate to the shell via context.go(), not context.push(). The goBranch() method uses go() internally, and mixing push + go causes unexpected behavior.

Common Pitfalls

  • Adding ChildRoute('/') inside ShellModularRoute (use concrete paths).
  • Using context.push() to navigate to a StatefulShellModularRoute (use context.go()).
  • Forgetting to render child / navigationShell in the layout builder.
  • Using ShellModularRoute when you need tab state preservation (use StatefulShellModularRoute instead).