Navigation and routing
This skill owns app navigation with go_router. There is exactly ONE GoRouter for the app, defined in lib/routing/, and every screen is reachable by a URL. Navigation is a data structure (routes + a pure redirect), not a pile of imperative Navigator.push calls.
Read the reference for the task at hand:
references/go-router-config.md — the single router, context.go vs context.push, path params vs state.extra, typed route helpers, errorBuilder/404.
references/guards-and-redirects.md — pure redirect functions, the Riverpod refreshListenable, auth + onboarding gates, avoiding redirect loops.
references/shells-and-deep-links.md — StatefulShellRoute.indexedStack for bottom-nav/rail shells, CustomTransitionPage, PopScope, and notification-payload → location mapping.
Run scripts/check_routing.sh before a PR.
Non-negotiable rules
- Exactly ONE
GoRouter, built in lib/routing/, wired once via MaterialApp.router in app.dart. Multiple routers fragment history, deep links, and back-button behaviour. The router is created behind a provider so guards can watch app state.
- Deep-linkable identity lives in PATH PARAMS, never in
state.extra. extra is a live Dart object: it is null on a cold start from a deep link and after process death / restoration. A screen that needs an id to rebuild must read it from state.pathParameters so the URL alone fully reconstructs the screen.
state.extra is ONLY an optional non-identity optimisation (a pre-fetched object to avoid a reload flash). The screen must still work — refetch by id — when extra is null.
- Use
context.go to replace the stack (declarative destinations, tabs, post-login home); use context.push to stack a screen you expect to pop back from (a detail, a modal flow). Mixing them wrong breaks the back button. Know which one every call site needs.
- Guards are PURE
redirect functions. redirect returns a new location String? (or null to allow) from GoRouterState + a snapshot of app state. No I/O, no navigation calls, no side effects inside redirect — it runs on every navigation and can run repeatedly.
- Reactive guards use a
refreshListenable, not polling. Bridge the Riverpod auth/onboarding provider to a Listenable; the router re-evaluates redirect whenever it fires. See state-management-riverpod.
- Redirects must be loop-free. Always allow the destination the guard sends you TO (e.g. never redirect
/sign-in back to /sign-in). Guard against A→B→A by checking the current location before redirecting.
- Tab/branch shells use
StatefulShellRoute.indexedStack. It preserves each branch's navigation stack and state across tab switches; a plain ShellRoute with an IndexedStack you wire by hand does not survive router rebuilds as cleanly. Switch branches with navigationShell.goBranch(index).
- Custom transitions go through
CustomTransitionPage and respect reduced motion. When the platform requests reduced motion, collapse to a no-op/fade. Read the flag from MediaQuery, resolve motion via the design system — see accessibility-as-code and design-system-structure.
- Intercept back / unsaved changes with
PopScope, not WillPopScope (removed). Set canPop: false and handle in onPopInvokedWithResult(bool didPop, T? result); only navigate away after the user confirms.
- Provide an
errorBuilder and a real 404/error route. An unmatched deep link must land on a designed error screen, never a red error box.
- Never hold a
BuildContext across an await before navigating. Capture GoRouter.of(context) (or the router) before the await, or guard with context.mounted after. See async-safety.
- A feature does not build its own router.
scaffold-feature-module registers a feature's GoRoute INTO this router's route list; features never instantiate GoRouter.
The single router
app.dart reads the router from a provider and hands it to MaterialApp.router. The router itself lives in routing/ and is the only place GoRouter(...) is constructed.
// routing/app_router.dart
final routerProvider = Provider<GoRouter>((ref) {
// Bridge Riverpod auth state to a Listenable the router can watch.
final refresh = ValueNotifier<int>(0);
ref.onDispose(refresh.dispose);
ref.listen(authNotifierProvider, (_, __) => refresh.value++);
return GoRouter(
initialLocation: Routes.home,
refreshListenable: refresh,
redirect: (context, state) => appRedirect(ref.read(authNotifierProvider), state),
errorBuilder: (context, state) => ErrorScreen(error: state.error),
routes: $appRoutes, // assembled from feature route lists
);
});
// app.dart — the only MaterialApp.router
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
return MaterialApp.router(
routerConfig: router,
theme: lightTheme,
darkTheme: darkTheme,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
);
}
}
Identity in the path, not in extra
// GOOD: id is in the URL — a cold-start deep link to /items/42 fully rebuilds.
GoRoute(
path: '/items/:id',
builder: (context, state) {
final id = state.pathParameters['id']!; // always present
// extra is an OPTIONAL fast-path; screen must work when it is null.
final preloaded = state.extra as Item?;
return ItemScreen(itemId: id, preloaded: preloaded);
},
),
// BAD: identity smuggled through extra — null on cold start / after process death.
context.push('/item', extra: item); // no id in the URL => not deep-linkable
Typed route helpers (constants, not string soup)
Prefer small constant/builder classes so call sites never hand-concatenate paths. go_router_builder codegen is an OPTIONAL upgrade, not the default.
// routing/routes.dart
abstract final class Routes {
static const home = '/';
static const items = '/items';
static String item(String id) => '/items/$id';
static const signIn = '/sign-in';
}
// call site
context.push(Routes.item(item.id));
go vs push
context.go(Routes.home); // replace whole stack: post-login, tab roots
context.push(Routes.item(id)); // stack a detail you'll pop back from
context.pop(result); // return up, optionally with a result
Anti-patterns
- Two
GoRouter instances, or a nested Navigator/MaterialApp inside a screen for "sub-navigation." Use nested routes / StatefulShellRoute.
- Passing a domain id through
state.extra and reading extra! in build — crashes on cold start.
- I/O,
ref.read of async work, or calling context.go INSIDE redirect. Redirect is pure and returns a location.
- A
redirect that can bounce forever because it also redirects its own target.
- Mixing
Navigator.pushNamed('/x') string routes with go_router — one navigation system only.
WillPopScope (removed) instead of PopScope.
- Awaiting then using the same
context to navigate without a mounted check.
- A feature package/folder constructing its own
GoRouter.
Definition of done
- One
GoRouter in lib/routing/, one MaterialApp.router in app.dart.
- Every screen reachable by a URL; every id-bearing screen reads its id from
state.pathParameters.
state.extra is only ever an optional optimisation; every such screen renders correctly with extra == null.
- Guards are pure
redirect functions with a refreshListenable; no redirect loops.
- Tab shells use
StatefulShellRoute.indexedStack; branch state survives tab switches.
- Transitions respect reduced motion;
errorBuilder + 404 route present.
PopScope guards unsaved changes; no BuildContext used across an await when navigating.
scripts/check_routing.sh passes.
Related skills
app-startup-and-bootstrap — owns main()/bootstrap() ordering and where MaterialApp.router is mounted.
state-management-riverpod — the auth/onboarding providers the refreshListenable bridges.
scaffold-feature-module — registers a feature GoRoute into this router.
adaptive-layout — chooses NavigationRail vs BottomNavigationBar by width for the shell.
accessibility-as-code — reduced-motion flag and semantics for navigation.
design-system-structure — the reduced-motion token / resolveMotion helper for transitions.
async-safety — BuildContext/mounted rules around awaited navigation.
local-notifications-scheduler — the notification payload whose pure mapper produces a location.
References
1---2name: navigation-and-routing3description: Enforces one app-wide GoRouter in lib/routing/ wired via MaterialApp.router, deep-linkable identity in path params never state.extra, context.go-vs-context.push discipline, redirect guards as pure functions driven by a Riverpod refreshListenable, StatefulShellRoute.indexedStack for branch-state-preserving BottomNavigationBar/NavigationRail shells, CustomTransitionPage transitions that respect reduced motion, PopScope (canPop/onPopInvokedWithResult) for unsaved-changes interception, and an errorBuilder 404 route. Use when adding routes, GoRoute, redirect, auth/onboarding gates, deep links, ShellRoute or nested navigation, bottom-nav/rail tab shells, page transitions, back-button/unsaved-changes handling, notification-payload-to-location mapping, typed routes, go_router_builder, or wiring go_router into app.dart.4---56# Navigation and routing78This skill owns app navigation with `go_router`. There is exactly ONE `GoRouter` for the app, defined in `lib/routing/`, and every screen is reachable by a URL. Navigation is a data structure (routes + a pure redirect), not a pile of imperative `Navigator.push` calls.910Read the reference for the task at hand:11- `references/go-router-config.md` — the single router, `context.go` vs `context.push`, path params vs `state.extra`, typed route helpers, `errorBuilder`/404.12- `references/guards-and-redirects.md` — pure `redirect` functions, the Riverpod `refreshListenable`, auth + onboarding gates, avoiding redirect loops.13- `references/shells-and-deep-links.md` — `StatefulShellRoute.indexedStack` for bottom-nav/rail shells, `CustomTransitionPage`, `PopScope`, and notification-payload → location mapping.1415Run `scripts/check_routing.sh` before a PR.1617## Non-negotiable rules18191. **Exactly ONE `GoRouter`, built in `lib/routing/`, wired once via `MaterialApp.router` in `app.dart`.** Multiple routers fragment history, deep links, and back-button behaviour. The router is created behind a provider so guards can watch app state.202. **Deep-linkable identity lives in PATH PARAMS, never in `state.extra`.** `extra` is a live Dart object: it is `null` on a cold start from a deep link and after process death / restoration. A screen that needs an id to rebuild must read it from `state.pathParameters` so the URL alone fully reconstructs the screen.213. **`state.extra` is ONLY an optional non-identity optimisation** (a pre-fetched object to avoid a reload flash). The screen must still work — refetch by id — when `extra` is `null`.224. **Use `context.go` to replace the stack (declarative destinations, tabs, post-login home); use `context.push` to stack a screen you expect to pop back from (a detail, a modal flow).** Mixing them wrong breaks the back button. Know which one every call site needs.235. **Guards are PURE `redirect` functions.** `redirect` returns a new location `String?` (or `null` to allow) from `GoRouterState` + a snapshot of app state. No I/O, no navigation calls, no side effects inside `redirect` — it runs on every navigation and can run repeatedly.246. **Reactive guards use a `refreshListenable`, not polling.** Bridge the Riverpod auth/onboarding provider to a `Listenable`; the router re-evaluates `redirect` whenever it fires. See `state-management-riverpod`.257. **Redirects must be loop-free.** Always allow the destination the guard sends you TO (e.g. never redirect `/sign-in` back to `/sign-in`). Guard against `A→B→A` by checking the current location before redirecting.268. **Tab/branch shells use `StatefulShellRoute.indexedStack`.** It preserves each branch's navigation stack and state across tab switches; a plain `ShellRoute` with an `IndexedStack` you wire by hand does not survive router rebuilds as cleanly. Switch branches with `navigationShell.goBranch(index)`.279. **Custom transitions go through `CustomTransitionPage` and respect reduced motion.** When the platform requests reduced motion, collapse to a no-op/fade. Read the flag from `MediaQuery`, resolve motion via the design system — see `accessibility-as-code` and `design-system-structure`.2810. **Intercept back / unsaved changes with `PopScope`, not `WillPopScope`** (removed). Set `canPop: false` and handle in `onPopInvokedWithResult(bool didPop, T? result)`; only navigate away after the user confirms.2911. **Provide an `errorBuilder` and a real 404/error route.** An unmatched deep link must land on a designed error screen, never a red error box.3012. **Never hold a `BuildContext` across an `await` before navigating.** Capture `GoRouter.of(context)` (or the router) before the await, or guard with `context.mounted` after. See `async-safety`.3113. **A feature does not build its own router.** `scaffold-feature-module` registers a feature's `GoRoute` INTO this router's route list; features never instantiate `GoRouter`.3233## The single router3435`app.dart` reads the router from a provider and hands it to `MaterialApp.router`. The router itself lives in `routing/` and is the only place `GoRouter(...)` is constructed.3637```dart38// routing/app_router.dart39final routerProvider = Provider<GoRouter>((ref) {40 // Bridge Riverpod auth state to a Listenable the router can watch.41 final refresh = ValueNotifier<int>(0);42 ref.onDispose(refresh.dispose);43 ref.listen(authNotifierProvider, (_, __) => refresh.value++);4445 return GoRouter(46 initialLocation: Routes.home,47 refreshListenable: refresh,48 redirect: (context, state) => appRedirect(ref.read(authNotifierProvider), state),49 errorBuilder: (context, state) => ErrorScreen(error: state.error),50 routes: $appRoutes, // assembled from feature route lists51 );52});53```5455```dart56// app.dart — the only MaterialApp.router57class MyApp extends ConsumerWidget {58 const MyApp({super.key});59 @override60 Widget build(BuildContext context, WidgetRef ref) {61 final router = ref.watch(routerProvider);62 return MaterialApp.router(63 routerConfig: router,64 theme: lightTheme,65 darkTheme: darkTheme,66 localizationsDelegates: AppLocalizations.localizationsDelegates,67 supportedLocales: AppLocalizations.supportedLocales,68 );69 }70}71```7273## Identity in the path, not in extra7475```dart76// GOOD: id is in the URL — a cold-start deep link to /items/42 fully rebuilds.77GoRoute(78 path: '/items/:id',79 builder: (context, state) {80 final id = state.pathParameters['id']!; // always present81 // extra is an OPTIONAL fast-path; screen must work when it is null.82 final preloaded = state.extra as Item?;83 return ItemScreen(itemId: id, preloaded: preloaded);84 },85),86```8788```dart89// BAD: identity smuggled through extra — null on cold start / after process death.90context.push('/item', extra: item); // no id in the URL => not deep-linkable91```9293## Typed route helpers (constants, not string soup)9495Prefer small constant/builder classes so call sites never hand-concatenate paths. `go_router_builder` codegen is an OPTIONAL upgrade, not the default.9697```dart98// routing/routes.dart99abstract final class Routes {100 static const home = '/';101 static const items = '/items';102 static String item(String id) => '/items/$id';103 static const signIn = '/sign-in';104}105106// call site107context.push(Routes.item(item.id));108```109110## go vs push111112```dart113context.go(Routes.home); // replace whole stack: post-login, tab roots114context.push(Routes.item(id)); // stack a detail you'll pop back from115context.pop(result); // return up, optionally with a result116```117118## Anti-patterns119120- Two `GoRouter` instances, or a nested `Navigator`/`MaterialApp` inside a screen for "sub-navigation." Use nested routes / `StatefulShellRoute`.121- Passing a domain id through `state.extra` and reading `extra!` in `build` — crashes on cold start.122- I/O, `ref.read` of async work, or calling `context.go` INSIDE `redirect`. Redirect is pure and returns a location.123- A `redirect` that can bounce forever because it also redirects its own target.124- Mixing `Navigator.pushNamed('/x')` string routes with go_router — one navigation system only.125- `WillPopScope` (removed) instead of `PopScope`.126- Awaiting then using the same `context` to navigate without a `mounted` check.127- A feature package/folder constructing its own `GoRouter`.128129## Definition of done130131- One `GoRouter` in `lib/routing/`, one `MaterialApp.router` in `app.dart`.132- Every screen reachable by a URL; every id-bearing screen reads its id from `state.pathParameters`.133- `state.extra` is only ever an optional optimisation; every such screen renders correctly with `extra == null`.134- Guards are pure `redirect` functions with a `refreshListenable`; no redirect loops.135- Tab shells use `StatefulShellRoute.indexedStack`; branch state survives tab switches.136- Transitions respect reduced motion; `errorBuilder` + 404 route present.137- `PopScope` guards unsaved changes; no `BuildContext` used across an await when navigating.138- `scripts/check_routing.sh` passes.139140## Related skills141142- `app-startup-and-bootstrap` — owns `main()`/`bootstrap()` ordering and where `MaterialApp.router` is mounted.143- `state-management-riverpod` — the auth/onboarding providers the `refreshListenable` bridges.144- `scaffold-feature-module` — registers a feature `GoRoute` into this router.145- `adaptive-layout` — chooses `NavigationRail` vs `BottomNavigationBar` by width for the shell.146- `accessibility-as-code` — reduced-motion flag and semantics for navigation.147- `design-system-structure` — the reduced-motion token / `resolveMotion` helper for transitions.148- `async-safety` — `BuildContext`/`mounted` rules around awaited navigation.149- `local-notifications-scheduler` — the notification payload whose pure mapper produces a location.150151## References152153- go_router package: https://pub.dev/packages/go_router154- go_router API docs: https://pub.dev/documentation/go_router/latest/155- Flutter navigation & routing: https://docs.flutter.dev/ui/navigation156- Deep linking: https://docs.flutter.dev/ui/navigation/deep-linking157- PopScope API: https://api.flutter.dev/flutter/widgets/PopScope-class.html