ZenRouter Skill
This project uses zenrouter — a Flutter router that supports imperative, declarative, and coordinator-based navigation. This skill covers the Coordinator + RouteModule pattern, which is the right choice when you need deep linking, URL sync, layouts, and modular feature organisation.
Deep-dive references — read these only when you need the specific topic:
File When to read ADVANCED.md Coordinator-as-Module, tab navigation, composable redirect rules, parameter route examples MIXIN.md Full reference for RouteGuard,RouteRedirect,RouteDeepLink,RouteTransition,RouteQueryParameters,RouteRestorableNAVIGATION.md When to use pushvsnavigatevsreplaceand other navigation methods
Key Types
| Type | Package | Purpose |
|---|---|---|
RouteTarget | zenrouter_core | Base class for all routes; identity via props |
RouteUnique | zenrouter_core | Mixin — adds URI identity; required for coordinator routes |
Coordinator<T> | zenrouter | Central hub: owns NavigationPaths, parses URIs, drives Flutter Router |
CoordinatorModular<T> | zenrouter_core | Mixin on Coordinator — splits route parsing across RouteModules |
RouteModule<T> | zenrouter_core | Handles one feature's URI patterns and navigation paths |
NavigationPath<T> | zenrouter | Mutable stack of routes; one per layout group |
IndexedStackPath<T> | zenrouter | Fixed set of routes for tab-bar style navigation |
RouteLayout<T> | zenrouter | Mixin — layout route that wraps nested routes (shell, tab bar, drawer, etc.) |
RouteRedirectRule<T> | zenrouter_core | Mixin — delegates redirect logic to a list of RedirectRules |
RedirectRule<T> | zenrouter_core | Single composable guard; returns continueRedirect, redirectTo, or stop |
1. Route Base Type
All routes in a coordinator must extend RouteTarget with RouteUnique:
abstract class AppRoute extends RouteTarget with RouteUnique {
@override
Widget build(covariant Coordinator coordinator, BuildContext context);
}
2. Coordinator
Simple (no modules)
class AppCoordinator extends Coordinator<AppRoute> {
late final homeStack = NavigationPath<AppRoute>.createWith(
label: 'home',
coordinator: this,
)..bindLayout(HomeLayout.new);
@override
List<StackPath> get paths => [...super.paths, homeStack];
@override
AppRoute parseRouteFromUri(Uri uri) => switch (uri.pathSegments) {
[] || ['home'] => HomeRoute(),
['product', final id] => ProductRoute(id: id),
_ => NotFoundRoute(uri: uri),
};
}
// Wire up:
MaterialApp.router(routerConfig: AppCoordinator())
Modular (with feature modules)
Add CoordinatorModular<T> to delegate URI parsing across feature modules:
class AppCoordinator extends Coordinator<AppRoute>
with CoordinatorModular<AppRoute> {
@override
Set<RouteModule<AppRoute>> defineModules() => {
AuthModule(this),
ShopModule(this),
ProfileModule(this),
};
@override
AppRoute notFoundRoute(Uri uri) => NotFoundRoute(uri: uri);
}
Rules:
- Module order in
defineModules()determines parsing priority — first non-null result wins. CoordinatorModularoverridesparseRouteFromUriautomatically; do not override it.notFoundRouteis called when all modules returnnull.
For nested feature groups with sub-modules, see Coordinator as Module in ADVANCED.md.
3. Route Module
class ShopModule extends RouteModule<AppRoute> {
ShopModule(super.coordinator);
late final shopStack = NavigationPath<AppRoute>.createWith(
coordinator: coordinator, // ← always the inherited `coordinator` field (= root)
label: 'shop',
)..bindLayout(ShopLayout.new);
@override
List<StackPath> get paths => [shopStack];
@override
FutureOr<AppRoute?> parseRouteFromUri(Uri uri) => switch (uri.pathSegments) {
['shop'] => ShopHomeRoute(),
['shop', 'products', final id] => ProductDetailRoute(id: id),
_ => null, // ← MUST return null for unrecognised URIs
};
}
Rules:
- Always return
nullfor unrecognised URIs so other modules can claim them. - Use
coordinator(inherited field) forNavigationPath.createWith— it always refers to the root coordinator that owns the navigation state. bindLayout(LayoutClass.new)takes the constructor, not an instance.
4. Route Definition
// Standard route
class ShopHomeRoute extends AppRoute {
@override
Object? get parentLayoutKey => ShopLayout; // matches RouteLayout.layoutKey (default: runtimeType)
@override
Uri toUri() => Uri.parse('/shop');
@override
Widget build(covariant AppCoordinator coordinator, BuildContext context) {
return ShopHomePage(
onProductTap: (id) => coordinator.push(ProductDetailRoute(id: id)),
);
}
}
// Route with parameters — must override props
class ProductDetailRoute extends AppRoute {
ProductDetailRoute({required this.id});
final String id;
@override
List<Object?> get props => [id];
@override
Object? get parentLayoutKey => ShopLayout;
@override
Uri toUri() => Uri.parse('/shop/products/$id');
@override
Widget build(covariant AppCoordinator coordinator, BuildContext context) =>
ProductDetailPage(id: id);
}
Rules:
parentLayoutKeymust exactly matchlayoutKeyof the targetRouteLayout. DefaultlayoutKeyisruntimeType, so using the layout classTypeis simplest.- Override
propsfor routes with parameters. toUri()is used for deep linking and URL sync.
For redirect-only routes, see RedirectRule in ADVANCED.md.
5. Layout
class ShopLayout extends AppRoute with RouteLayout<AppRoute> {
@override
StackPath<AppRoute> resolvePath(covariant AppCoordinator coordinator) =>
coordinator.getModule<ShopCoordinator>().shopStack;
// layoutKey defaults to runtimeType — override only if you need a custom value
// @override Object get layoutKey => 'ShopLayout';
@override
Widget build(covariant AppCoordinator coordinator, BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Shop')),
body: buildPath(coordinator), // renders the active child route
);
}
}
Rules:
- Use
buildPath(coordinator)to render child routes — do not callsuper.build(). resolvePathmust return the exactNavigationPaththat wasbindLayout-ed in the module.- For tab navigation, see IndexedStackPath in ADVANCED.md.
6. Navigation
coordinator.push(ProductDetailRoute(id: '42')); // add to stack
coordinator.navigate(ShopHomeRoute()); // pop-to-existing or push new
coordinator.replace(SettingsRoute()); // full state reset
coordinator.pop(); // pop top route
// Navigate from a URI string (e.g. deep link):
final route = await coordinator.parseRouteFromUri(Uri.parse('/shop/products/42'));
coordinator.push(route!);
Redirect rules run automatically on every navigation call.
For full details on
pushReplacement,pushOrMoveToTop,tryPop,recover, and decision flowcharts, see NAVIGATION.md.
7. File Structure Convention
lib/src/router/
├── coordinator.dart ← Root Coordinator or Coordinator-as-Module
├── route.dart ← Base route type (e.g. AppRoute)
├── _public.dart ← Barrel: export module + public routes + public rules
├── rules/
│ ├── auth_required.dart
│ └── force_redirect.dart
└── routes/
├── (auth)/ ← Route group (shares a layout)
│ ├── _layout.dart ← Layout for this group
│ ├── sign_in.dart ← /sign-in
│ └── forgot_password.dart
├── (dashboard)/
│ ├── _layout.dart
│ ├── _index.dart ← Index / redirect-only route
│ └── transactions/ ← URI segment directory
│ ├── _index.dart ← /transactions
│ └── [id].dart ← /transactions/:id (named parameter route)
│ └── blog/
│ ├── _layout.dart ← blog layout
│ └── [...slug].dart ← /blog/* (catch-all parameter route)
└── not_found.dart
Conventions:
(group)/— parenthesised directories are layout groups (organisational only). They do not appear in the URI. Routes inside share a layout.group/— bare directories (no parentheses) do appear in the URI.transactions/→ the URI includes/transactions/...._layout.dart— theRouteLayoutfor its group; prefixed with_because it's structural, not a user-facing route._index.dart— the index route for a directory (often a redirect-only route).[param].dart— a named parameter route file. The brackets mirror dynamic URI segments (e.g.[id].dart→/transactions/:id).[...param].dart— a catch-all parameter route file. Captures all remaining URI segments as a single list (e.g.[...slug].dart→/blog/*)._public.dart— barrel file that exports only public symbols.rules/— reusableRedirectRuleimplementations.
For detailed examples of parameter route classes, see Named Parameter Routes and Catch-All Parameter Routes in ADVANCED.md.
8. Naming Conventions
Route Classes
| Pattern | Example | When |
|---|---|---|
<Feature>Route | SignInRoute, TransactionRoute | Standard page route |
<Feature>DetailRoute | TransactionDetailRoute | Detail page with [id] |
<Feature>IndexRoute | DashboardIndexRoute | Index / redirect-only route |
<Feature>Tab | HomeTab, ShopTab | Tab in an IndexedStackPath |
NotFoundRoute | NotFoundRoute | 404 catch-all |
Layout, Module, and Rule Classes
| Pattern | Example | When |
|---|---|---|
<Feature>Layout | AuthLayout, DashboardLayout | Layout shell |
<Feature>Module | AuthModule, ShopModule | Simple RouteModule |
<Feature>Coordinator | ShopCoordinator | Coordinator-as-Module (has sub-modules) |
<Condition>Rule | AuthRequiredRule, AlreadyAuthRule | Redirect rule |
URI Patterns
| Pattern | Example | Route |
|---|---|---|
/feature | /sign-in | SignInRoute |
/feature/:id | /transaction/abc123 | TransactionDetailRoute(id: 'abc123') |
/group/feature | /shop/products | ProductListRoute |
/group/feature/:id | /shop/products/42 | ProductDetailRoute(id: '42') |
Use kebab-case for URI segments. Use singular nouns for resource detail paths (/transaction/:id not /transactions/:id).
NavigationPath labels use kebab-case: 'auth', 'dashboard', 'shop-products'.
9. Adding a New Feature: Checklist
- Create route — extend base route type; set
parentLayoutKey,toUri(),build(),props. - Register in
parseRouteFromUriof the owning module. - Export from
_public.dartbarrel if navigated to from outside. - New layout group? — create
(group)/_layout.dartwithRouteLayout, newNavigationPathwithbindLayout, add topaths. - New module? — create
module.dartextendingRouteModule<T>, register in parent'sdefineModules(). - New feature group with sub-modules? — use Coordinator-as-Module pattern.
Common Mistakes
| Mistake | Fix |
|---|---|
parseRouteFromUri in a module not returning null for non-owned URIs | Must return null so other modules can claim the URI |
parentLayoutKey doesn't match layoutKey | Default layoutKey is usually runtimeType — use the layout class Type as parentLayoutKey |
Forgetting ...super.paths when overriding paths | Always spread super.paths to include root and inherited module paths |
Overriding parseRouteFromUri on a CoordinatorModular coordinator | Don't — CoordinatorModular handles it; override notFoundRoute instead |
Standalone Coordinator returning null from parseRouteFromUri | Standalone coordinators must never return null; add a catch-all case |