Navigation and routing
SkillDev toolsEnforces 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.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the Navigation and routing skill
What this skill tells your AI
The instructions your AI receives, as published by zakariaf/flutter-skills in skills/navigation-and-routing/SKILL.md and read by ahel’s review.
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.govscontext.push, path params vsstate.extra, typed route helpers,errorBuilder/404.references/guards-and-redirects.md— pureredirectfunctions, the RiverpodrefreshListenable, auth + onboarding gates, avoiding redirect loops.references/shells-and-deep-links.md—StatefulShellRoute.indexedStackfor 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 inlib/routing/, wired once viaMaterialApp.routerinapp.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.extrais a live Dart object: it isnullon a cold start from a deep link and after process death / restoration. A screen that needs an id to rebuild must read it fromstate.pathParametersso the URL alone fully reconstructs the screen. state.extrais ONLY an optional non-identity optimisation (a pre-fetched object to avoid a reload flash). The screen must still work — refetch by id — whenextraisnull.- Use
context.goto replace the stack (declarative destinations, tabs, post-login home); usecontext.pushto 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
redirectfunctions.redirectreturns a new locationString?(ornullto allow) fromGoRouterState+ a snapshot of app state. No I/O, no navigation calls, no side effects insideredirect— it runs on every navigation and can run repeatedly. - Reactive guards use a
refreshListenable, not polling. Bridge the Riverpod auth/onboarding provider to aListenable; the router re-evaluatesredirectwhenever it fires. Seestate-management-riverpod. - Redirects must be loop-free. Always allow the destination the guard sends you TO (e.g. never redirect
/sign-inback to/sign-in). Guard againstA→B→Aby 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 plainShellRoutewith anIndexedStackyou wire by hand does not survive router rebuilds as cleanly. Switch branches withnavigationShell.goBranch(index). - Custom transitions go through
CustomTransitionPageand respect reduced motion. When the platform requests reduced motion, collapse to a no-op/fade. Read the flag fromMediaQuery, resolve motion via the design system — seeaccessibility-as-codeanddesign-system-structure. - Intercept back / unsaved changes with
PopScope, notWillPopScope(removed). SetcanPop: falseand handle inonPopInvokedWithResult(bool didPop, T? result); only navigate away after the user confirms. - Provide an
errorBuilderand a real 404/error route. An unmatched deep link must land on a designed error screen, never a red error box. - Never hold a
BuildContextacross anawaitbefore navigating. CaptureGoRouter.of(context)(or the router) before the await, or guard withcontext.mountedafter. Seeasync-safety. - A feature does not build its own router.
scaffold-feature-moduleregisters a feature'sGoRouteINTO this router's route list; features never instantiateGoRouter.
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
GoRouterinstances, or a nestedNavigator/MaterialAppinside a screen for "sub-navigation." Use nested routes /StatefulShellRoute. - Passing a domain id through
state.extraand readingextra!inbuild— crashes on cold start. - I/O,
ref.readof async work, or callingcontext.goINSIDEredirect. Redirect is pure and returns a location. - A
redirectthat 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 ofPopScope.- Awaiting then using the same
contextto navigate without amountedcheck. - A feature package/folder constructing its own
GoRouter.
Definition of done
- One
GoRouterinlib/routing/, oneMaterialApp.routerinapp.dart. - Every screen reachable by a URL; every id-bearing screen reads its id from
state.pathParameters. state.extrais only ever an optional optimisation; every such screen renders correctly withextra == null.- Guards are pure
redirectfunctions with arefreshListenable; no redirect loops. - Tab shells use
StatefulShellRoute.indexedStack; branch state survives tab switches. - Transitions respect reduced motion;
errorBuilder+ 404 route present. PopScopeguards unsaved changes; noBuildContextused across an await when navigating.scripts/check_routing.shpasses.
Related skills
app-startup-and-bootstrap— ownsmain()/bootstrap()ordering and whereMaterialApp.routeris mounted.state-management-riverpod— the auth/onboarding providers therefreshListenablebridges.scaffold-feature-module— registers a featureGoRouteinto this router.adaptive-layout— choosesNavigationRailvsBottomNavigationBarby width for the shell.accessibility-as-code— reduced-motion flag and semantics for navigation.design-system-structure— the reduced-motion token /resolveMotionhelper for transitions.async-safety—BuildContext/mountedrules around awaited navigation.local-notifications-scheduler— the notification payload whose pure mapper produces a location.
References
- go_router package: https://pub.dev/packages/go_router
- go_router API docs: https://pub.dev/documentation/go_router/latest/
- Flutter navigation & routing: https://docs.flutter.dev/ui/navigation
- Deep linking: https://docs.flutter.dev/ui/navigation/deep-linking
- PopScope API: https://api.flutter.dev/flutter/widgets/PopScope-class.html
Signals
- GitHub stars
- 36
- Forks
- 10
- Last commit
- Sep 2026
ahel review
K6low
bundled executables the agent is told to run
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Item type
- skill
- Key
navigation-and-routing- Source
- github.com/zakariaf/flutter-skills