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.
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:
- The actual point of Navigator 2.0 is not web-like routing. It is a paradigm shift in where the navigation stack lives.
- The dominant routing packages quietly walk back that shift by reintroducing URLs as the source of truth.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 surgeryPage 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.
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.
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.
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.
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.
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.
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.
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.
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.
I love where this is going