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 so switching tabs preserves scroll position, form state, and navigation history.

Container:

  • IndexedStack via StatefulShellRoute.indexedStack when nothing selects an animated swap: no transition, no transitionDuration / reverseTransitionDuration, and no global Modular.configure defaultTransition.
  • Animated swap (aligned with go_transitions (opens in a new tab)) when any of those is set, or when you pass navigatorContainerBuilder — for example presets from StatefulShellBranchTransitions (withGoTransition, fade presets, …). The explicit builder wins over transition/durations.

You can combine partial overrides (only transition, only durations, or both); missing pieces fall back to Modular.configure defaults and GoTransition.defaultDuration (including defaultTransitionDuration in configure).

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 (ModuleRoute)

Each branch is declared with routes (required). To mount a full module (binds + routes), wrap it in a ModuleRoute with a distinct path per branch — duplicate URLs across branches break GoRouter redirects.

Branch module binds are registered lazily on first visit (via the ModuleRoute redirect) and disposed when the shell exits:

StatefulShellModularRoute(
  builder: (context, state, navigationShell) => AppShell(
    navigationShell: navigationShell,
  ),
  branches: [
    ModularBranch(
      routes: [
        ModuleRoute('/home', module: HomeBranchModule()),
      ],
    ),
 
    ModularBranch(
      routes: [
        ChildRoute('/search', child: (_, __) => SearchPage()),
      ],
    ),
 
    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('/', child: (_, __) => HomePage()),
      ];
}

The outer ModuleRoute('/home', …) defines the segment for this tab; the branch module typically uses ChildRoute('/') as its root page under that segment (final path: …/home).

ModuleBranch (shortcut)

When a branch is exactly one ModuleRoute(path, module: …), you can use ModuleBranch instead of spelling ModularBranch + list. Behavior and lifecycle are the same.

StatefulShellModularRoute(
  builder: (context, state, navigationShell) => AppShell(
    navigationShell: navigationShell,
  ),
  branches: [
    ModuleBranch('/home', module: HomeBranchModule()),
    ModuleBranch('/settings', module: SettingsModule()),
  ],
)

ModuleBranch parameters

ParameterTypeDescription
pathStringRequired (positional). URL segment for this tab (unique per branch under the shell). Same convention as ModuleRoute(path, …).
moduleModuleRequired. Module mounted at path (binds + routes).
navigatorKeyGlobalKey<NavigatorState>?Same as ModularBranch.
initialLocationString?Same as ModularBranch.
restorationScopeIdString?Same as ModularBranch.
observersList<NavigatorObserver>?Same as ModularBranch.

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 — navigators stay mounted (IndexedStack or animated container; branches are not disposed on tab switch)
Shell exits navigation treeAll branch modules are disposed, then the shell module

Whether the shell uses IndexedStack or an animated container, inactive branches typically stay mounted. Branch modules are not disposed when switching tabs — 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>Required. Non-empty list (ChildRoute, ModuleRoute, etc.) for this branch
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

Use ModuleRoute('/segment', module: YourModule()) inside routes when the branch needs its own binds and route tree.

Branch transitions (go_transitions)

Optional transition (e.g. GoTransitions.slide.toTop.withFade), transitionDuration, reverseTransitionDuration, or navigatorContainerBuilder control how the shell switches branch content. Omitting them uses IndexedStack only when no effective transition applies (see above). Prefer StatefulShellBranchTransitions.withGoTransition presets when wrapping custom logic isn’t needed.

StatefulShellModularRoute Parameters

ParameterTypeDescription
branchesList<ModularBranch>Required. Branches for the shell (ModularBranch, ModuleBranch, …)
builderWidget Function(context, state, navigationShell)?Builds the shell layout
pageBuilderPage Function(context, state, navigationShell)?Custom page builder
transitionGoTransition?Branch-to-branch animation (inherits from Modular.configure default when omitted)
transitionDurationDuration?Forward duration (GoTransition.defaultDuration when omitted; reflects configure defaultTransitionDuration)
reverseTransitionDurationDuration?Optional reverse duration
navigatorContainerBuilderShellNavigationContainerBuilder?Overrides IndexedStack/default transition wiring when non-null
notifyRootObserverboolForwarded to StatefulShellRoute (default true)
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
Typical branch containerchild swaps in one navigatorIndexedStack or animated builder (defaults + presets)

Best practices

  • Do not place ChildRoute('/') directly inside ShellModularRoute. Use concrete paths (e.g. /home) or nest under ModuleRoute.
  • For StatefulShellModularRoute, give each tab a unique URL: prefer ModuleRoute('/pos', …), ModuleRoute('/settings', …) per branch instead of several inner ChildRoute('/') that all resolve to the same path.
  • Keep the shell builder minimal: layout and navigation only. Push business logic into child modules.
  • Use ModuleBranch('/segment', module: …) or ModularBranch(routes: [ ModuleRoute(...) ]) to isolate each tab’s module (binds + routes).
  • 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).
  • Several StatefulShellModularRoute branches mapping to the same full path (e.g. multiple inner / without distinct ModuleRoute prefixes) — breaks redirects / named routes.
  • 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).