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('/')insideShellModularRoute. 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:
IndexedStackviaStatefulShellRoute.indexedStackwhen nothing selects an animated swap: notransition, notransitionDuration/reverseTransitionDuration, and no globalModular.configuredefaultTransition.- 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 fromStatefulShellBranchTransitions(withGoTransition, fade presets, …). The explicit builder wins overtransition/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
StatefulShellModularRouteinside aModuleRoute, 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
| Parameter | Type | Description |
|---|---|---|
path | String | Required (positional). URL segment for this tab (unique per branch under the shell). Same convention as ModuleRoute(path, …). |
module | Module | Required. Module mounted at path (binds + routes). |
navigatorKey | GlobalKey<NavigatorState>? | Same as ModularBranch. |
initialLocation | String? | Same as ModularBranch. |
restorationScopeId | String? | Same as ModularBranch. |
observers | List<NavigatorObserver>? | Same as ModularBranch. |
Dependency Injection Lifecycle
| Event | What happens |
|---|---|
| Shell enters navigation tree | Shell module binds are registered |
| First visit to a branch | Branch module binds are registered (lazy) |
| Switch between branches | Nothing — navigators stay mounted (IndexedStack or animated container; branches are not disposed on tab switch) |
| Shell exits navigation tree | All 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
| Parameter | Type | Description |
|---|---|---|
routes | List<ModularRoute> | Required. Non-empty list (ChildRoute, ModuleRoute, etc.) for this branch |
navigatorKey | GlobalKey<NavigatorState>? | Custom navigator key for this branch |
initialLocation | String? | Initial route when switching to this branch |
restorationScopeId | String? | Restoration scope for state restoration |
observers | List<NavigatorObserver>? | Navigation observers for this branch |
Use
ModuleRoute('/segment', module: YourModule())insiderouteswhen 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
| Parameter | Type | Description |
|---|---|---|
branches | List<ModularBranch> | Required. Branches for the shell (ModularBranch, ModuleBranch, …) |
builder | Widget Function(context, state, navigationShell)? | Builds the shell layout |
pageBuilder | Page Function(context, state, navigationShell)? | Custom page builder |
transition | GoTransition? | Branch-to-branch animation (inherits from Modular.configure default when omitted) |
transitionDuration | Duration? | Forward duration (GoTransition.defaultDuration when omitted; reflects configure defaultTransitionDuration) |
reverseTransitionDuration | Duration? | Optional reverse duration |
navigatorContainerBuilder | ShellNavigationContainerBuilder? | Overrides IndexedStack/default transition wiring when non-null |
notifyRootObserver | bool | Forwarded to StatefulShellRoute (default true) |
redirect | FutureOr<String?> Function(context, state)? | Redirect logic |
parentNavigatorKey | GlobalKey<NavigatorState>? | Parent navigator key |
restorationScopeId | String? | Restoration scope |
shellKey | GlobalKey<StatefulNavigationShellState>? | Key to access shell state |
When to Use Which
| Feature | ShellModularRoute | StatefulShellModularRoute |
|---|---|---|
| Shared layout | Yes | Yes |
| Preserves tab state | No | Yes |
| Own navigator per tab | No | Yes |
| Back button per tab | No | Yes |
| Branch-level DI | No | Yes |
| Use case | Simple layouts, sidebars | Bottom navigation, tab bars |
| Typical branch container | child swaps in one navigator | IndexedStack or animated builder (defaults + presets) |
Best practices
- Do not place
ChildRoute('/')directly insideShellModularRoute. Use concrete paths (e.g./home) or nest underModuleRoute. - For
StatefulShellModularRoute, give each tab a unique URL: preferModuleRoute('/pos', …),ModuleRoute('/settings', …)per branch instead of several innerChildRoute('/')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: …)orModularBranch(routes: [ ModuleRoute(...) ])to isolate each tab’s module (binds + routes). - Use
StatefulShellModularRoutewhen you need state to persist across tab switches. - Navigate to the shell via
context.go(), notcontext.push(). ThegoBranch()method usesgo()internally, and mixingpush+gocauses unexpected behavior.
Common pitfalls
- Adding
ChildRoute('/')insideShellModularRoute(use concrete paths). - Several
StatefulShellModularRoutebranches mapping to the same full path (e.g. multiple inner/without distinctModuleRouteprefixes) — breaks redirects / named routes. - Using
context.push()to navigate to aStatefulShellModularRoute(usecontext.go()). - Forgetting to render
child/navigationShellin the layout builder. - Using
ShellModularRoutewhen you need tab state preservation (useStatefulShellModularRouteinstead).