Skip to content

Instantly share code, notes, and snippets.

@Mastersam07
Last active June 28, 2026 09:51
Show Gist options
  • Select an option

  • Save Mastersam07/5db9dd00ccdc66977a811844a9b90526 to your computer and use it in GitHub Desktop.

Select an option

Save Mastersam07/5db9dd00ccdc66977a811844a9b90526 to your computer and use it in GitHub Desktop.
Flutter Routing Is a Pre-Dart-3 Design

Routes as Values

Most Flutter routers were architected before Dart 3 had sealed types and pattern matching. One has been redesigned around them. Here's the value-oriented shape no one has built yet.


The thesis

Flutter's two most-adopted routing libraries — go_router and auto_route — were architected in a world before Dart had sealed classes, exhaustive pattern matching, and records. Both shipped their core API in 2021–2022, and both still organize themselves around the same primitive: a string path, parsed at runtime, with type safety bolted on via build_runner.

It's 2026. Dart 3 has been out for three years. The bulk of the routing ecosystem has not been redesigned around the language features that would dissolve most of its current pain. The result is two excellent, well-maintained packages that both feel half-translated — like libraries written in a different language, decorated with annotations to make Dart feel more like the language they were designed for.

One library, zenrouter, has been written in the Dart 3 spirit — sealed types, exhaustive resolvers, no codegen requirement. We'll get to it later in the article. Its existence narrows the claim I'm making, but does not eliminate it: the design space for Dart 3-native routing has at least two plausible shapes, and only one of them currently has an implementation.

This article makes three arguments:

  1. The actual point of Navigator 2.0 is not web-like routing. It is a paradigm shift in where the navigation stack lives.
  2. The dominant routing packages quietly walk back that shift by reintroducing URLs as the source of truth.
  3. A Dart 3-native routing library can take at least two shapes: an OOP-and-mixins shape (zenrouter) and a value-oriented, codec-as-bridge shape that pushes pattern matching and sealed types harder. The second shape hasn't been built, and the gap is wide enough to be worth filling.

What Navigator 2.0 actually says

Most discussions frame Navigator 2.0 as "the thing you need for web URLs and deep linking." That is the most visible consequence, but it is not the architectural claim. The claim is about who owns the navigation stack.

In Navigator 1.0, the Navigator widget is the source of truth. It maintains an internal stack of routes; your application code imperatively mutates it via push, pop, pushReplacement. Your app state is downstream of whatever the Navigator currently displays. Want to know what's on the stack? Ask the Navigator. Want to change it? Call methods on it.

Navigator 2.0 inverts this. Your application state is the source of truth, and the navigation stack is derived from it. You hand the Navigator a List<Page> produced from your state; when state changes, you produce a new list; the framework diffs and animates. The stack becomes pages = f(state). The Navigator is a pure function in the middle.

Once you have that inversion, web URL routing falls out for free, because URLs are just one possible serialization of your state. But so does mobile deep linking. So does state restoration after process death. So does the browser back button, multiple parallel navigators, and the testability of navigation as a pure function. Web URLs are the most legible payoff; they are not the goal.

The Router API formalizes this with three pieces: a RouterDelegate that owns navigation state and builds the Navigator with the current page list; a RouteInformationParser that turns incoming route information (a URL, a deep link, a saved-state blob) into state; and a BackButtonDispatcher that feeds system back events into the delegate. Notice the symmetry: route info flows into state, state flows out as a page list. The Navigator's only job is to render the projection.

If you internalize one thing from this section, it is the inversion: the stack is a function of state, not the other way around. Everything that follows hinges on whether the library you use respects that inversion or quietly undoes it.

What the dominant packages get wrong

The Router API is verbose. Almost nobody uses it directly. They reach for go_router or auto_route — and the moment they do, they encounter an API whose primary primitive is a string path.

GoRoute(
  path: '/products/:id',
  builder: (context, state) {
    final id = state.pathParameters['id']!;
    return ProductScreen(id: id);
  },
),

This is the structural problem. The library's mental model is URLs first, application state derived from them. That is the inverse of what Navigator 2.0 says. Type safety, when you want it, has to be bolted back on by codegen: go_router_builder, auto_route_generator. Both generate the typed wrapper that the language could express natively if the library were organized around it. The codegen step exists to recover what was lost by choosing strings as the primitive.

This single design choice — URL paths as the primary representation — cascades into most of the day-to-day pain.

The extra cascade

URLs can carry strings, not domain objects. When you want to pass a Cart, a callback, or a non-trivial state object from one screen to the next, you have nowhere to put it on the URL side, so you put it in extra — an untyped escape hatch that:

  • Breaks deep linking (it isn't in the URL).
  • Doesn't survive process-death restoration (it isn't serialized).
  • Is invisible to web URL parsing.
  • Loses its type at the boundary.

Real production apps end up threading IDs through URLs and re-fetching everywhere — not because they want to, but because extra is the only alternative and extra doesn't survive the system. The "pass a domain object to the next screen" pattern, which is the most natural thing in the world, has been functionally removed.

The guards cascade

go_router's redirect is a single global callback that has to know about every auth condition in your app, returns a String? (back to strings), and has well-documented recursion footguns. Async conditions — "wait until auth is settled, then evaluate" — don't fit naturally. auto_route's imperative guard chain is more composable but harder to reason about when guards interact.

Neither offers what the architecture suggests it should: guards as pure functions of (authState, proposedStack) → finalStack, composed in a pipeline, testable in isolation.

The shell cascade

Bottom-nav with per-tab state scoping is the most common production pattern in Flutter. Every non-trivial app has it. It is also the heaviest pattern to express in current libraries. go_router's StatefulShellRoute.indexedStack works, but it takes nested configuration; auto_route's AutoTabsRouter is lighter, but still ceremonial. Scoping a Bloc/Cubit/signal container per tab requires threading dependencies through layers that exist only because routing is structured around path matching rather than around the actual UI shell hierarchy.

A pattern that ships in every non-trivial app should not be a multi-page tutorial.

The modal cascade

await Navigator.push<Result>(...) was a clean Navigator 1.0 idiom. In declarative routing it sits awkwardly outside the "stack is state" model. Both libraries support it; both make it feel like the second-class API. Modal flows — show a sequence of screens, return a typed result, dismiss — end up using raw Navigator.of(context).push, which then doesn't compose with the router's history.

You end up running two navigation systems in parallel: one for the "real" routes, one for modal flows. That is exactly the thing the Router API was supposed to dissolve.

Missing first-class concerns

Beyond cascades, several things are simply absent:

  • Transitions are coupled to route definitions. Direction-aware animations (forward vs. back) are clunky. Shared-element transitions are a separate problem to be solved per project.
  • Composability across feature modules is weak. Shipping a self-contained feature with its own internal routes, mountable at a path prefix by another team, is a manual exercise both libraries don't really help with. For SDKs that vend screens — payment SDKs, KYC flows, embedded checkout — this is a real gap.
  • Adaptive routing — master-detail on tablet, stacked on phone, side-by-side on desktop — has no first-class support. You hand-roll it from breakpoint queries inside the screen, and your navigation tree silently differs per platform.

None of this is anyone's fault. These libraries were excellent design choices in 2020, when Dart's type system had less to work with. The problem is that the design choices have ossified — and the libraries that built on top of those choices inherit the constraints.

Codegen is the receipt

Both go_router_builder and auto_route_generator ship optional codegen that produces typed wrappers over their string-based APIs — GoRouteData subclasses, @RoutePage annotations, typed push helpers. They work, and they're well-maintained. They're also a tell.

A /products/:id route, after parsing, is a Map<String, String>. The id is a string until your screen casts it. The generator's job is to undo this loss: parse the string back into a typed value, expose it as a property on a generated class, so your screen code can treat the route as the type it always was. Dart 3 lets you skip the round-trip. A sealed class with a typed constructor — ProductDetail(String id) — never lost the type to begin with. There is nothing to recover, so there is nothing to generate.

Macros, briefly, would have folded the codegen step into the compiler. The January 2025 cancellation closed that door. The remaining options are codegen-as-it-exists-today — slow incremental builds, generated artifacts your imports depend on, a parallel build_runner step in your workflow, type safety that arrives in your editor a few seconds after you save — or language features that never lost the type to begin with. Pattern matching, sealed classes, and exhaustive switches are the second option.

A library that doesn't lose the type doesn't need to recover it. Codegen isn't a feature; it's a receipt the library hands you for the type information it threw away.

What zenrouter does (and doesn't)

Before the rest of this article gets accused of pretending zenrouter doesn't exist: it does, it's at v2.1.0, and it's the exception that has to be addressed.

zenrouter takes sealed types seriously. Routes extend RouteTarget, carry props-based identity, and the imperative resolver is an exhaustive switch (route) over your sealed hierarchy. The "URL as primary representation" complaint above doesn't apply — zenrouter parses URIs into typed route objects, not strings-with-parameters. It is a genuinely Dart 3-aware router.

It is also a comprehensive product. Three navigation paradigms (imperative, declarative, coordinator-with-deep-linking) under one API. State restoration after process death via RouteRestorable. A DevTools extension for inspecting routes and testing deep links. An optional code generator for file-based routing. Migration guides from go_router and auto_route. A verified publisher, multi-package architecture (zenrouter_core, zenrouter_devtools, zenrouter_file_generator), active CI. Download numbers are modest but real.

So saying "a Dart 3 routing library hasn't been written yet" isn't exactly correct. zenrouter has been written in the Dart 3 spirit. The question is whether zenrouter is the answer Dart 3 wants from a router, or one of several possible shapes the design could take.

Where zenrouter diverges from the shape I'd build:

Routes carry behavior. RouteTarget requires (or strongly encourages) a Widget build(...) method. The route is the screen, in the StatelessWidget mental model. That's familiar to most Flutter devs and not wrong — but it mixes pure navigation data with presentation. Routes can't easily be const if they reference instance methods, are harder to serialize, harder to share across feature modules without dragging screen code with them, and the screen-aware route is doing two jobs.

The value-oriented alternative: a route is just data —

final class ProductDetail extends AppRoute {
    const ProductDetail(this.id);
    final String id;
}

— and rendering is a separate function:

Widget buildPage(context, route) => switch (route) { ... };

The route doesn't know how it looks. Two consequences: routes are trivially shareable and serializable, and one place handles every screen exhaustively. The compiler tells you when you've added a route variant and forgotten to handle it.

Behavior attaches to routes via mixins. RouteGuard, RouteRedirect, RouteDeepLink, RouteRestorable, RouteTransition, RouteLayout. When a single route needs to redirect under condition X, that's local and tidy. When you have cross-cutting concerns — auth across thirty screens — you with RouteGuard on every screen, and you write the same popGuard logic in thirty places (or factor it into a helper, and have every route remember to call the helper).

The pipeline alternative: guards as (currentStack, proposedStack) → finalStack functions composed in a list. The route doesn't know about auth; auth knows about routes. The same authGuard runs against every navigation, evaluating proposed.any((r) => r is RequiresAuth) once. Pure-Dart testable, no widget tree, no route-mocks. The mixin approach scales poorly because every cross-cutting concern requires every route to opt in.

No per-branch typing in shells. IndexedStackPath.createWith([FeedTab(), ProfileTab(), SettingsTab()]) types all branches against the parent AppRoute — pushing a SettingsDetail onto the Feed tab's stack compiles. The fix is per-branch sealed subtypes — Feed uses FeedRoute, Settings uses SettingsRoute, both extending the same AppRoute — and a shell that holds typed routers per branch, heterogeneously. Then pushing a SettingsDetail into the Feed router is a compile error. zenrouter doesn't have this; the article's section on shells (below) sketches what closing this gap looks like.

Coordinator as central hub. You subclass Coordinator<AppRoute>, override parseRouteFromUri, register NavigationPath and IndexedStackPath instances, implement RouterConfig<RouteTarget>. Layouts are routes-with-RouteLayout-mixin that reference each other via Type get layout => HomeLayout. The hierarchy is built from Type comparisons at runtime.

The thinner alternative: the configuration is a data type — {mainStack: List<R>, shellState: ShellConfig?} — the codec is a pure bidirectional function, the delegate is just the renderer, and layouts are widgets composed normally. Fewer concepts to learn, fewer indirections at runtime, no Type reflection to debug when the layout hierarchy doesn't resolve the way you expected.

Modal flows aren't first-class — and here zenrouter shares a limitation with the routers it positions against. None of go_router, auto_route, or zenrouter offers await router.run<T>(SomeFlow()) returning a typed Future<T?>. For modal sequences with results, all three send you to showDialog, showModalBottomSheet, or push a modal route imperatively — the same parallel system the Router API was meant to dissolve. The modal cascade is the one place where the entire current ecosystem, dominant routers and Dart-3-aware alternative alike, walks back the Router API's promise.

None of this makes zenrouter wrong. It is a perfectly defensible Dart 3-flavored router with strong feature breadth, real adoption support, and one of the best stories around process-death restoration in the ecosystem. If you need state restoration today and you don't want to build it yourself, you should look at it.

This article isn't saying "no one has done this." It is saying: here is the value-oriented, codec-as-bridge, per-branch-typed, pattern-match-everywhere flavor of the same design that nobody has built, and that flavor is meaningfully different from zenrouter's OOP-and-mixins flavor. Two libraries can both be Dart 3-aware and still disagree on what Dart 3 wants from routing. zenrouter is the first answer. The rest of this article tries to sketch the second.

What the second shape looks like

If you were starting from scratch today, with Dart 3 as the substrate and an explicit decision to treat routes as values rather than objects-with-methods, the primitive shifts. Below is a sketch of what the surface area could look like — not as a finished proposal, but as a demonstration that none of this requires inventing anything new.

Routes are a typed sealed sum, with no methods on them

sealed class AppRoute {
  const AppRoute();
}

final class Home extends AppRoute {
  const Home();
}

final class ProductList extends AppRoute {
  const ProductList({this.category});
  final String? category;
}

final class ProductDetail extends AppRoute {
  const ProductDetail(this.id);
  final String id;
}

final class Checkout extends AppRoute {
  const Checkout(this.cart);
  final Cart cart;
}

Routes are const, value-equal by their fields, and have no idea how they're rendered. Navigation is list manipulation over List<AppRoute>:

router.push(const ProductDetail('sku-42'));
router.pop();
router.replace(const Home());
router.set([const Home(), const ProductList(category: 'shoes')]); // direct surgery

Page resolution is pattern matching, in exactly one place, which gives you exhaustiveness checking for free:

Widget buildPage(AppRoute route) => switch (route) {
  Home()                        => const HomeScreen(),
  ProductList(:final category)  => ProductListScreen(category: category),
  ProductDetail(:final id)      => ProductDetailScreen(id: id),
  Checkout(:final cart)         => CheckoutScreen(cart: cart),
};

Add a new variant and every page resolver becomes a compile error pointing at exactly where to handle it. No extra. No string parameters. No codegen. No build() on the route class.

This is the first place the value-oriented shape diverges from zenrouter. zenrouter would put the build on the route. The shape I'm describing puts it on a free function, deliberately.

URLs are a codec, not a definition language

You write a small bidirectional mapping between your sealed type and Uri — once, in one place, separate from where routes are defined:

class AppRouteCodec implements RouteCodec<AppRoute> {
  @override
  Uri encode(AppRoute route) => switch (route) {
    Home()                       => Uri(path: '/'),
    ProductList(:final category) => Uri(
      path: '/products',
      queryParameters: {
        if (category != null) 'category': category,
      },
    ),
    ProductDetail(:final id) => Uri(path: '/products/$id'),
    Checkout()               => Uri(path: '/checkout'),
  };

  @override
  AppRoute? decode(Uri uri) { /* … */ }
}

Web apps register the codec and get URL routing. Mobile-only apps skip it entirely. Deep links, push notifications, and state restoration all flow through the codec — and only through the codec — so the moment your codec is correct, every entry point Just Works. Critically, the URL is no longer load-bearing for the definition of your routes. If you don't have URLs, you don't have to invent paths.

zenrouter does something similar through RouteUnique.toUri() and Coordinator.parseRouteFromUri, but the encode and decode live on different objects — the route encodes itself, the coordinator decodes. Splitting the bridge across two locations costs you the property that lets the codec be unit-tested as a single pure function. The shape I'm describing keeps both halves in one place.

Guards are pure-function transforms in a pipeline

typedef Guard = FutureOr<List<AppRoute>> Function(
  AuthState auth,
  List<AppRoute> proposed,
);

List<AppRoute> authGuard(AuthState auth, List<AppRoute> proposed) {
  if (proposed.any((r) => r is RequiresAuth) && !auth.isAuthenticated) {
    return const [Login()];
  }
  return proposed;
}

final router = Router(
  initial: const [Home()],
  guards: [authGuard, onboardingGuard, featureFlagGuard],
);

Composable, testable without a widget tree, async-aware. No global redirect callback that knows everything; no imperative guard lifecycle; no with RouteGuard on every screen. The pipeline composes left to right, and each guard is just a function you can unit-test:

test('auth guard redirects unauthenticated users to login', () {
  final result = authGuard(
    AuthState.unauthenticated(),
    const [Home(), Account()],
  );
  expect(result, const [Login()]);
});

No widget tree, no router mock, no integration harness.

Shells and tabs are primitives, and each branch carries its own type

sealed class HomeRoute extends AppRoute { const HomeRoute(); }
final class HomeRoot extends HomeRoute { const HomeRoot(); }
final class ProductDetail extends HomeRoute { const ProductDetail(this.id); final String id; }

sealed class DiscoverRoute extends AppRoute { const DiscoverRoute(); }
final class DiscoverRoot extends DiscoverRoute { const DiscoverRoot(); }
final class FeedItem extends DiscoverRoute { const FeedItem(this.id); final String id; }

final homeRouter = Router<HomeRoute>(initial: const HomeRoot());
final discoverRouter = Router<DiscoverRoute>(initial: const DiscoverRoot());

BranchedShell(
  branches: [
    Branch<HomeRoute>(router: homeRouter, pageBuilder: ...),
    Branch<DiscoverRoute>(router: discoverRouter, pageBuilder: ...),
  ],
  chromeBuilder: ...,
)

Inside a Home branch screen, context.router<HomeRoute>().push(const ProductDetail('42')) typechecks; trying to push a DiscoverRoute is a compile error, not a runtime concern. The shell aggregates heterogeneously-typed routers; each branch installs its own scope; back-button routing pops within the active branch before falling through to the parent.

This is the second place the value-oriented shape diverges from zenrouter. Heterogeneous per-branch typing requires sealed subtypes that the branch widgets enforce — and that requires routes to be data, not OOP objects, because otherwise the type variance on build(context, route) makes the branch list invariant in awkward ways.

Modal sub-flows are first-class with typed results

final int? quantity = await router.run<int>(ConfirmAddToCart('sku-42'));
if (quantity != null) {
  await cart.add('sku-42', quantity);
}

A flow is a sub-stack with its own internal routes and scope; it completes with a typed value, or is cancelled. Inside the flow's screens, context.completeFlow<int>(value) resolves the awaiter; context.dismissFlow() cancels it. Flows compose: a payment flow can open an add-card flow on top of itself, both modal layers mounted at once, completions unwinding LIFO so the inner flow's typed result resumes the outer flow's await before that flow's own result resumes the caller's. This is the natural unit for auth, onboarding, multi-step forms, payment confirmations — exactly the patterns that SDKs vending screens need to express, and exactly where every current library, including the Dart-3-aware one, pushes you back into raw Navigator.push.

Routes compose as modules

final router = Router(
  modules: [
    AuthModule(),
    ShopModule(mountAt: '/shop'),
    AccountModule(mountAt: '/account'),
  ],
);

A feature ships a RouteModule<SubRoute> with its own sealed subtype, its own codec, its own guards. The app composes modules and decides where each one mounts. This is what enables real feature-team plug-and-play — and what an SDK needs to vend screens to consumer apps without forcing them to wire each route by hand.

Adaptive layout is a builder concern

The route stays as data. The adaptive decision sits one level up, in the function that turns the stack into renderable pages. A second pattern match, this time over (previous route, current route, breakpoint), picks the rendering shape:

PageResult buildAdaptive(BuildContext context, AppRoute route, StackContext ctx) {
  final wide = MediaQuery.of(context).size.width >= 700;
  return switch ((ctx.previous, route, wide)) {
    // ProductList + ProductDetail at wide widths: render the two as a
    // single master-detail page, side by side.
    (ProductList(), ProductDetail(:final id), true) =>
      MergedPage(MasterDetailLayout(/*...*/), absorbs: 1),

    // Everything else: one stack entry, one rendered page.
    _ => StandalonePage(buildPage(route)),
  };
}

Two things to notice. The router's stack is invariant across breakpoints: [ProductList, ProductDetail(42)] is what's on the stack whether the screen is 400px or 1400px wide. The adaptive builder just chooses how to render that stack — as one page or as two. Tapping a different product in master-detail still pushes another ProductDetail onto the stack, the back button still pops one entry, the URL still serializes the same way. The adaptive policy never leaks into the navigation model.

And no new route surface is needed. No AdaptiveRoute mixin, no AdaptivePolicy declared per route, no separate adaptive route hierarchy. The pattern match catches the case and the renderer responds. Adding a new adaptive layout pair — folders list and folder detail, conversation list and conversation detail — is one more case in the switch, not a new route type.

This is the same move as the page builder, applied one level deeper. When routes are data, the natural place to put rendering decisions, adaptive or otherwise, is in pure functions over that data, not in mixins on the data.

The ergonomic API on top is boring and imperative

context.router<HomeRoute>().push(const ProductDetail('sku-42'));

The declarative-stack-from-state architecture is the implementation, not the surface area. Most call sites should never need to think about it. You only descend into the substrate when you need to do something interesting — programmatic stack surgery, custom guards, modular composition, per-branch URL routing into shell state.

The gap is real

None of this requires features that don't exist. Sealed classes, records, pattern matching, exhaustive switching, extension types — all shipped in Dart 3. The Router API has been stable for years. The pieces are on the table.

zenrouter has assembled them in one configuration: routes-with-methods, mixins-for-behavior, coordinator-as-central-hub, shared route type across shell branches. That configuration is internally consistent and shipping in production. It is one valid answer.

The configuration I've described — routes-as-values, guards-as-pipeline, codec-as-single-bridge, per-branch-typed shells, typed flow results — is a different answer. Not better in every dimension. Less battle-tested, no DevTools yet, no process-death restoration story. But it pushes the type system harder, keeps the runtime mental model thinner, and treats every cross-cutting concern as a pipeline transformation rather than an opt-in mixin. For an SDK that vends screens, for an app that wants flutter test coverage on its navigation, for a team that values compile-time over runtime checks — the trade-offs are meaningfully different.

The reasons it hasn't been built before are partly inertia (go_router has the official imprimatur, auto_route has community mass), partly path dependence (both packages built up large API surfaces that would be expensive to rewrite), and partly that the people who would build it tend to look at zenrouter, see that a Dart 3-aware router exists, and stop. The honest cost of not doing it is paid every time a developer writes state.pathParameters['id']!, reaches for extra to pass a domain object, writes a six-page tutorial for a bottom nav with scoped state, or runs build_runner to generate the type safety a sealed class would have given for free — and every time someone picks up zenrouter, hits the per-branch typing gap or the missing modal-flow primitive, and either lives with it or rolls their own escape hatch.

The macros cancellation in January 2025 reinforces this. Dart's metaprogramming story is now firmly "augmentations + build_runner" rather than language-level codegen, which means the codegen tax go_router and auto_route impose is not going to be quietly absorbed into the compiler. The language-level features that do exist — pattern matching, sealed types, exhaustiveness — become the only way out, and they reward the libraries that take them seriously.

The honest summary: Flutter's dominant routing story works. It just works in a language that no longer exists. Dart 3 has the type machinery to express navigation natively — sealed routes, typed stacks, pattern-matched dispatch, URLs as a codec — and zenrouter is the one library that has internalized this in one shape. The codec-as-bridge value-oriented shape, with per-branch typing and typed modal flows as primitives, is the other shape, and it is still on the table.

That last sentence is a complaint with a shape. The shape is a package. Someone should build it.

@jogboms

jogboms commented May 31, 2026

Copy link
Copy Markdown

I love where this is going

@definev

definev commented Jun 2, 2026

Copy link
Copy Markdown

Hello, zenrouter author here!

First, i want to thank you for writing about my little router library!

I had the same thought as you when writing Zenrouter: why not use modern features in Dart 3 like sealed classes with exhautive types to handle routing? Go_router and autoroute matchers like /foo/:id are also very easy to use with switch cases ['foo', final id].

And as you said, other routing libraries trying to apply the entire app-state to the URI and extras goes against the principles of Nav 2.0. In my opinion, the URI should only retain the user intent; the data can be easily reconstructed once the app opens to the correct screen. That design choice allows Zenrouter to handle the restoration process very subtly and easily!

I will answer each of your concern.

1. Routes carry behavior.

The RouteTarget is a base abstract class and from that you can add more behavior to it like RouteRedirect for redirection-behavior. RouteIdentity for create an identity for route. And because it adding behavior to the base class so it doesn't related to serialize and deserialize. For example, in zenrouter if you implement Coordinator pattern, you have serialize/deseralize for free via RouteUnique which is including RouteIdentity<Uri> mixin.

I know you want a pure function approach and genuinely i want that too. Function should be pure but since it pure so it's self-contained, the homeDefaultRedirect function only know redirect behavior for Home, buildPage only know about translate route to widget. In my experience, I want keeps all logic related to RouteTarget located locally and sitting near together for easier understand all business logic that RouteTarget has. It's make me avoid context-switching and i feels more comfortable with it. Here is the example i'm talk about:

/* With functional style shape */
/* The logic scattered into multiple file */
// builder.dart
Widget buildPage(AppRoute route) => switch (route) {
  Home()                                    => const HomeScreen(),
  ProductList(:final category)  => ProductListScreen(category: category),
};

// redirect.dart
AppRoute? homeDefaultRedirect(AppRoute route) => switch (route) {
  Home()                                    => const ProductDefaultList(),
  _                                               => null,
};

// routes.dart
sealed class AppRoute {
  const AppRoute();
}
final class Home extends AppRoute {
  const Home();
}
final class ProductDefaultList extends AppRoute {
  const ProductDefaultList();
}
final class ProductList extends AppRoute {
  const ProductList({this.category});
  final String? category;
}

/* With zenrouter mixin-system */
/* A little bit verbose but all the related route logic is self-contained in the route definition */
// base.dart
abstract class AppRoute extends RouteTarget {}
// home.dart
class Home extends AppRoute with RouteRedirect<AppRoute>  {
   AppRoute? redirect() => ProductDefaultList();
   Widget build() => HomeScreen();
}
// product.default.dart
class ProductDefaultList extends AppRoute {
   Widget build() => ProductDefaultListScreen();
}
// product.:category.dart
class ProductList extends AppRoute {
   ProductList({this.category});
   final String? category;
   Widget build() => ProductListScreen(category: category);
}

The const is a trade-off here, since i want a route self-contained so i needs to bind their life-cycle (init -> onUpdate -> onDiscard) to the RouteTarget object. It can't immutable but in the routing behavior landscape, your rarely create hundred of route so reusing RouteTarget as a pure data doesn't add much to performance.

2. Behavior attaches to routes via mixins.

RouteGuard, RouteRedirect, RouteDeepLink, RouteRestorable, RouteTransition, RouteLayout. When a single route needs to redirect under condition X, that's local and tidy. When you have cross-cutting concerns — auth across thirty screens — you with RouteGuard on every screen, and you write the same popGuard logic in thirty places (or factor it into a helper, and have every route remember to call the helper).

The cross-cutting concern is real :-) I encounter that with exact scenerios you describe: Reuse auth redirect rule. I add a wrapper around RouteRedirect: RouteRedirectRule to extract redirect rule outside route object.

// Extract redirect rule outside route object
class NeedAuthRule extends RedirectRule<MerchantRoute> {
  Future<RedirectResult<MerchantRoute>> _redirect(CoordinatorModular coordinator, MerchantRoute route) async {
    return .redirectTo(SignInRoute(redirectUri: route.toUri()));
  }

  @override
  Future<RedirectResult<MerchantRoute>> redirectResult(CoordinatorModular coordinator, MerchantRoute route) async {
    if (route.parentLayoutKey == 'AuthLayout') return const .continueRedirect();

    try {
      final tokenPair = await container.read(getTokenPairQuery.future);
      if (tokenPair == null) return _redirect(coordinator, route);
      return const .continueRedirect();
    } catch (_) {
      return _redirect(coordinator, route);
    }
  }
}


// Reuse the RedirectRule in route object
class CreateRefundTicketRoute extends MerchantRoute
    with RouteTransition, MerchantDialogTransition, RouteRedirectRule {
  @override
  Object? get parentLayoutKey => 'DashboardLayout';

  @override
  Widget build(covariant CoordinatorCore<RouteUri> coordinator, BuildContext context) => MerchantCard(
    modifier: (token, style) => style.copyWith(padding: .zero, borderRadius: .zero),
    child: CreateTicketView(),
  );

  @override
  Uri toUri() => Uri.parse('/ticket/create');
  @override
  List<RedirectRule<RouteTarget>> get redirectRules => [
    NeedAuthRule(),
    NeedPermissionRule(
      permission: const .new(scope: .refundTransation, action: .create),
      deniedRoute: MerchantDeniedRoute(
        parentLayoutKey: 'DashboardLayout',
        builder: (context, child) => DeniedAccessView(
          primaryActionLabel: 'Back to Home'.untranslated,
          onPrimaryPressed: () => context.merchantCoordinator.replaceUri(Uri.parse('/')),
        ),
      ),
    ),
  ];
}

But about the RouteGuard reuse i didn't thought about that yet. I think i can easily create a wrapper RouteGuardRule and extract it from route object.

3. No per-branch typing in shells

I don't think it's help much but if you want, zenrouter can do it. I tend to flatten out everything in routing since it doesn't add value when i read the code.

abstract class AppRoute extends RouteTarget {}

class AuthenticationRoute extends AppRoute {}

abstract class TabRoute extends AppRoute {}

class UserTab extends TabRoute {}

class SettingsTab extends TabRoute {}

4. Coordinator as central hub.

The Type get layout is a legacy design i choose in zenrouter 1.0. In zenrouter 2.0 i switch it to Object? get parentLayoutKey so the layoutResolver process is more reliable and lib user has more control about layout behavior (reuse same route behavior for multiple layout. Like put the CreateTicketView in DashboardLayout or FloatingLayout).
Zenrouter also provide RouteModule to help you composing parseRouteFromUri together.
Coordinator is also implement RouteModule that mean you can grouping Coordinators into your root Coordinator. It's help you create a tree-like structure with RouteModule as a leaf node and CoordinatorModular as a parent node. With above ability you're able to split Coordinator across multiple packages and each package own their deeplink handler via RouteModule.parseRouteFromUri.

/// App coordinator implementation.
/// Aggregates routes from feature modules and app-level routes.
class WebPortalCoordinator extends Coordinator<MerchantRoute>
    with CoordinatorModular {
  @override
  Set<RouteModule<MerchantRoute>> defineModules() => {
    MerchantAuthenticationModule(this),
    MerchantDashboardModule(this),
  };

  @override
  MerchantRoute notFoundRoute(Uri uri) => NotFoundRoute();
}

class MerchantDashboardModule extends Coordinator<MerchantRoute>
    with CoordinatorModular<MerchantRoute>, CoordinatorModuleOnly {
  MerchantDashboardModule(this.hostCoordinator);

  @override
  final CoordinatorModular<MerchantRoute> hostCoordinator;

  DashboardConfig get _config => container.read(merchantDashboardConfigStore);

  @override
  Set<RouteModule<MerchantRoute>> defineModules() => {
    MerchantDashboardRouteModule(this),
    if (_config.isEnabled(.transaction)) MerchantTransactionRouteModule(this),
    if (_config.isEnabled(.refund)) MerchantRefundRouteModule(this),
    if (_config.isEnabled(.wallet)) MerchantWalletRouteModule(this),
    if (_config.isEnabled(.ticket)) MerchantTicketRouteModule(this),
    if (_config.isEnabled(.download)) MerchantDownloadManagerRouteModule(this),
  };
}

class MerchantAuthenticationModule extends RouteModule<MerchantRoute> {
  MerchantAuthenticationModule(super.coordinator, {this.isAdminPortal = false});

  final bool isAdminPortal;

  late final authPath = NavigationPath<MerchantRoute>.createWith(coordinator: coordinator, label: 'merchant_auth')
    ..bindLayout(AuthLayout.new);

  @override
  List<StackPath<RouteTarget>> get paths => [authPath];

  @override
  MerchantRoute? parseRouteFromUri(Uri uri) => switch (uri.pathSegments) {
    ['sign-in'] => SignInRoute(
      redirectUri: Uri.tryParse(uri.queryParameters['redirectTo'] ?? '/'),
      isAdminPortal: isAdminPortal,
    ),
    ['auth', 'redirect'] => () {
      final sanitizedUri = Uri.tryParse(uri.toString().replaceFirst('#', '?')) ?? uri;
      return AuthRedirectRoute(queries: sanitizedUri.queryParameters);
    }(),
    _ => null,
  };
}

5. Modal flows aren’t first-class

This is a painpoint i can't find ergonomic enough solution yet - Mix non Uri contributed route into current route stack. The nav 1.0 push way just a one way. The good enough solution i found is putting a dedicated NavigationPath and overlay it in layoutBuilder in Coordinator then use it normally like nav 1.0 push/pop.

@Mastersam07

Mastersam07 commented Jun 2, 2026

Copy link
Copy Markdown
Author

Thanks @definev for taking the time to write all of that — A lot of what you wrote should land in any revision i'll make to this piece on how I'd argue this case. Going point by point.

1. Routes carry behavior

Your locality argument is real and I undersold it in the article. Co-locating definition, redirect, and build in one file does cut context-switching when you're reasoning about a single route in isolation, and the functional-style version genuinely scatters logic across three files. That scattering has a cost I glossed over.

Where I'd still push: the trade-off goes the other way at certain scales. With pure-data routes I can test a guard pipeline without rendering a widget, serialize a route into an analytics event without inventing a parallel representation, and use routes as map keys for things like dedupe or replay. Routes-with-build couple navigation logic to widget concerns in a way that's invisible until you want to reason about navigation outside the widget tree. So the honest framing is probably: locality wins for reading a single route's behavior, values win for composing and testing routes as a set. Neither is wrong; they optimize for different things, and the paper framed one as categorically right.

The const point is fair. You're right that apps don't create thousands of route instances per session, so the flyweight argument is mostly aesthetic. I'd still want const for free equality and hashable identity (useful for things like deduplicating analytics events keyed on routes), but that's a smaller benefit than I implied.

2. Behavior attaches to routes via mixins

RouteRedirectRule seems to be the right move, and structurally it IS converging toward a pipeline — redirectRules returning List<RedirectRule<RouteTarget>> is "this route runs through these rules in order," which is the same shape, just per-route instead of per-router. NeedAuthRule and NeedPermissionRule are exactly the kind of extracted, reusable cross-cutting rules I had in mind.

The remaining delta is smaller than I made it sound. The pipeline-at-router level means adding a new cross-cutting rule (say, NeedFeatureFlag) doesn't require touching every route class — you add it once to the configuration and apply it by pattern matching at runtime. With redirectRules-as-getter you still have to remember to include the rule on each route that needs it. But this is a delta of "where the rule list lives," not "do you have a pipeline." Your design has internalized the cross-cutting concern more than the article credited.

3. No per-branch typing in shells

I think we may be answering different questions here, so let me restate what I meant.

A bottom-nav shell with Feed, Search, Settings tabs, each with its own back stack. The per-branch typing I'm describing isn't abstract class TabRoute extends AppRoute with concrete tab subclasses (which all share a single AppRoute hierarchy and live in a flat type space). It's that each tab's stack is typed as a different type parameter — Feed's stack accepts only FeedRoute, Settings's only SettingsRoute. Pushing a SettingsProfile into the Feed tab's stack is a compile error rejected by the type system before any code runs, because the Feed branch's push signature only accepts FeedRoute.

Roughly this shape at the shell level:

sealed class FeedRoute extends AppRoute { ... }
sealed class SettingsRoute extends AppRoute { ... }

final feedBranch     = Branch<FeedRoute>(initial: const FeedHome());
final settingsBranch = Branch<SettingsRoute>(initial: const SettingsHome());

feedBranch.push(const SettingsProfile());
// compile error: argument is SettingsRoute, expected FeedRoute

Your example flattens everything under a shared AppRoute so router.push(anything) is accepted at every call site. Whether that's a bug in practice depends on how your shell dispatches at runtime — but the type system isn't catching the mistake before runtime.

I agree this doesn't add much for apps with 3–5 tabs and small route counts per tab. Where it earns its keep is apps with substantial per-tab subgraphs (a feed feature with 30+ screens, a settings/admin area with 20+ screens, etc.), where routing accidents become a real failure mode and the compile-time check is genuinely useful. The "doesn't add value when I read the code" critique lands on the simple case; I'd argue it does add value at scale.

4. Coordinator as central hub

The v2 design substantially closes this objection, and I should have engaged with it more directly in the article. Object? get parentLayoutKey is a real improvement over Type get layout, and the CoordinatorModular composition story — particularly your MerchantDashboardModule example with feature flags — is exactly the kind of modular composition I argued was missing. That's not a central hub, it's a tree, and feature modules can live in separate packages with their own deeplink handlers. I underread how much of this you've built.

The smaller concern that remains (mine, not the article's): a feature module via Coordinator<R> with CoordinatorModular carries more framework dependency than a pure-data module that exposes a parseRouteFromUri and a builder map. Whether that ceremony is worth it depends on what you get in return — and in your case you get layout binding, lifecycle, and aggregation, which are substantial. So this lands as a smaller design-taste question rather than the architectural problem I framed it as.

5. Modal flows aren't first-class

This is where the most concrete gap remains, and your honest acknowledgment that you haven't found an ergonomic solution matches what I see reading zenrouter's source for modal use cases. The dedicated NavigationPath + overlay workaround does the job but doesn't give the call site a typed return.

The shape I'd propose makes a modal flow a route that participates in a separate sub-stack with its own typed result:

final int? quantity = await router.run<int>(const ConfirmAddToCart('sku-42'));
if (quantity case final quantity?) {
  await cart.add('sku-42', quantity);
}

Inside the flow, context.completeFlow<int>(value) resolves the awaiter; context.dismissFlow() resolves it with null. Nested flows compose with LIFO completion — a payment flow can open AddCardFlow from inside one of its own screens, and the inner flow's typed result resumes the outer flow's await before that flow resolves its own caller:

// inside PaymentFlow's screen:
Future<void> _addCard() async {
  final newCard = await context.router<AppRoute>().run<CardId>(const AddCardFlow());
  if (newCard case final newCard?) {
    setState(() => _cards.add(newCard));
  }
}

The framework handles the completer threading; user code just awaits.

I don't think this requires zenrouter to change shape. Perhaps you could add a TypedNavigationPath<T> primitive that produces a Future<T?>, and the mixin system would let RouteTarget instances declare themselves as flow-completable (something like RouteCompletable<T>)? It's an additive primitive, not a redesign.

Random thoughts

The biggest thing I'd revise in any follow-up is that I overstated the gap between zenrouter's current design and what a value-oriented router would offer. Specifically: v2's parentLayoutKey plus CoordinatorModular substantially closes the modularity gap, and RouteRedirectRule converges toward the pipeline shape for the most common cross-cutting concern. The real remaining deltas are smaller and more design-philosophy than design-quality:

  • Routes-as-data vs routes-with-build (locality vs composability trade-off)
  • Per-branch typed shells (matters at scale, less so for small apps)
  • First-class typed modal flows (the concrete ergonomic gap)

Two questions back, if you're up for it:

  1. When you sketch RouteGuardRule, would it follow the same shape as RouteRedirectRule — a separate class with a guardRules getter on the route — or would you push it toward router-level configuration where guards are matched into routes by pattern rather than declared on each one?

  2. For modal flows: do you see TypedNavigationPath<T> as something that could land in zenrouter as an additive primitive, or do you think the path-and-overlay shape is good enough for what you encounter in practice?

Thanks again for engaging.

@definev

definev commented Jun 3, 2026

Copy link
Copy Markdown
  1. I want locality for guards so RouteGuardRule will look like RouteRedirectRule. It's good for lib users to get used to the new behavior since it's pretty similar.
  2. I don't understand how TypedNavigationPath should look like. Can you elaborate on it via a zenrouter feature request github issue? I would love to discuss it with you 👍

More about design choices in zenrouter: locate routes as much as possible even if we need to duplicate code (i.e.: repeating NeedAuthRule for each route that needs authentication). I think reusability doesn't conflict with this design choice. You can create a getter like userPermissionRule that returns both NeedAuthRule and NeedPermissionRule and use it in the route to avoid repeating RedirectRule. But it introduces a layer of complexity when we read the code and debug it later, so I tend to flatten out everything if possible.

@Mastersam07

Copy link
Copy Markdown
Author

@definev The canonical Navigator 1.0 idiom was:

final Result? r = await Navigator.push<Result>(context, ...);

This is awkward for a couple of reasons - its a one way push, no typed return at the call site. Earlier you said:

This is a painpoint i can't find ergonomic enough solution yet - Mix non Uri contributed route into current route stack. The nav 1.0 push way just a one way. The good enough solution i found is putting a dedicated NavigationPath and overlay it in layoutBuilder in Coordinator then use it normally like nav 1.0 push/pop.

But doing this loses 2 things: having a typed result flowing back to the caller and await at the call site.

I was thinking something around the idea:

// Open a flow, await a typed result. null = dismissed.
final CardId? card = await coordinator.runFlow<CardId>(const AddCardFlow());
if (card case final card?) {
  setState(() => _cards.add(card));
}

And inside the flow's screens, the flow resolves itself:

context.coordinator.completeFlow<CardId>(newCard); // resolves the awaiter with a value
context.coordinator.dismissFlow();                 // resolves the awaiter with null

Not sure if the correct API is context.coordinator.* but hopefully you get what i mean. The user writes await; the framework threads the completer. No second navigation system, no manual overlay bookkeeping.

Will open an issue on the repo detailing this. cc: @definev

@Mastersam07

Copy link
Copy Markdown
Author

Reading the zenrouter src i think all the pieces are there. There is per path dialog representation(StackTransition.dialog and a layout builder), push<T>(entry) which we can reuse on a flow but the T? it returns is the runFlow's return.

maybe a runFlow<T> on the coordinator. We promote the manual overlay workaround to a managed runFlow primitive

Future<T?> runFlow<T extends Object>(
  RouteTarget entry, {
  StackTransitionResolver<RouteTarget>? present, // defaults to StackTransition.dialog
});

void completeFlow<T extends Object>(T? value);

void dismissFlow();  

In theory, dismissFlow() == completeFlow(null).

The call sites awaits runFlow, it is typed with no manual path/overlay setup.

final CardId? card = await coordinator.runFlow<CardId>(const AddCardEntry());
if (card case final card?) setState(() => _cards.add(card));

Inside the flow screens,

context.coordinator.completeFlow<CardId>(newCard);
context.coordinator.dismissFlow();

@definev

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment