Back to skills
extension
Category: Development & EngineeringNo API key required

zenrouter

Implement and extend routing using the zenrouter Coordinator pattern. Use this skill when the user asks to add a route, feature module, layout, redirect rule, redirect guard, nested coordinator, or navigate programmatically. Triggers on: route, router, routing, coordinator, CoordinatorModular, RouteModule, NavigationPath, IndexedStackPath, RouteLayout, RouteRedirectRule, RedirectRule, RedirectResult, parseRouteFromUri, push, pop, replace, navigate, deep link, toUri, parentLayoutKey, layoutKey, bindLayout, RouteUnique, RouteGuard, RouteRedirect, notFoundRoute, zenrouter.

personAuthor: jakexiaohubgithub

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/guard rules, parameter route examples | | MIXIN.md | Full reference for RouteGuard, RouteGuardRule, RouteRedirect, RouteDeepLink, RouteTransition, RouteQueryParameters, RouteRestorable | | NAVIGATION.md | When to use push vs navigate vs replace and 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 redirect; returns continueRedirect, redirectTo, or stop | | RouteGuardRule<T> | zenrouter_core | Mixin — delegates pop-guard logic to a list of GuardRules | | GuardRule<T> | zenrouter_core | Single composable pop guard; returns null / true / false |


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.
  • CoordinatorModular overrides parseRouteFromUri automatically; do not override it.
  • notFoundRoute is called when all modules return null.

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 null for unrecognised URIs so other modules can claim them.
  • Use coordinator (inherited field) for NavigationPath.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:

  • parentLayoutKey must exactly match layoutKey of the target RouteLayout. Default layoutKey is runtimeType, so using the layout class Type is simplest.
  • Override props for 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 call super.build().
  • resolvePath must return the exact NavigationPath that was bindLayout-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 — the RouteLayout for 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/ — reusable RedirectRule implementations.

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

  1. Create route — extend base route type; set parentLayoutKey, toUri(), build(), props.
  2. Register in parseRouteFromUri of the owning module.
  3. Export from _public.dart barrel if navigated to from outside.
  4. New layout group? — create (group)/_layout.dart with RouteLayout, new NavigationPath with bindLayout, add to paths.
  5. New module? — create module.dart extending RouteModule<T>, register in parent's defineModules().
  6. 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 |