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 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
StatefulShellModularRouteinside aModuleRoute, 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
| 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 — all branches stay alive in IndexedStack |
| Shell exits navigation tree | All branch modules are disposed, then the shell module |
Because
IndexedStackkeeps 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
| Parameter | Type | Description |
|---|---|---|
routes | List<ModularRoute>? | Direct routes for this branch |
module | Module? | A module whose routes and binds will be used |
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 |
Each branch must have either
routesormodule, not both.
StatefulShellModularRoute Parameters
| Parameter | Type | Description |
|---|---|---|
branches | List<ModularBranch> | Required. The branches for the shell |
builder | Widget Function(context, state, navigationShell)? | Builds the shell layout |
pageBuilder | Page Function(context, state, navigationShell)? | Custom page builder |
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 |
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
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). - 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).