Flutter Navigator 2.0: Declarative Routing Explained with a Working RouterDelegate
Navigator 2.0 turns Flutter's route stack into data. This guide walks through a working RouterDelegate, explains why Page keys matter, and covers the mistakes that break declarative routing.
25 Jun 2026, 05:42 UTC

The short answer
Navigator 2.0 lets you describe your app's navigation stack as data — a list of pages — instead of issuing imperative Navigator.push() and pop() calls. When the underlying data changes, Flutter diffs the page list and rebuilds the visible stack for you. That is what makes deep links, browser URL synchronization, and state-driven navigation (for example, "show the login page whenever the user is signed out") straightforward instead of fragile.
If your app only ever pushes screens in response to button taps, Navigator 1.0 is still fine and simpler. Reach for 2.0 when the route must be a function of app state, or when you target the web and need URLs to work.
How the mechanism works
Three pieces cooperate:
- RouterDelegate — an object you write that owns the current list of pages and builds a
Navigatorfrom it. It extendsChangeNotifier, so callingnotifyListeners()tells the Router to rebuild. - RouteInformationParser — converts an incoming URL string (from a deep link or the browser address bar) into your route state, and back.
- Router — the widget that wires the two together.
MaterialApp.router()creates one for you.
The key idea: your delegate holds plain data (say, a selected item ID), and the build method turns that data into a list of Page objects. Pages are declarative descriptions of routes; the Navigator inflates them into actual routes.
A minimal working configuration
This example supports two routes — a list at / and a detail screen at /details — with no imperative push calls. Run it in any Flutter project (tested pattern applies to Flutter 2.x stable and later; the API is unchanged in current 3.x releases):
class AppState extends ChangeNotifier {
String? selectedItem;
void select(String id) {
selectedItem = id;
notifyListeners(); // required — the Router rebuilds from this
}
void clearSelection() {
selectedItem = null;
notifyListeners();
}
}
class AppRouterDelegate extends RouterDelegate<AppState>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppState> {
final AppState state;
@override
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
AppRouterDelegate(this.state) {
state.addListener(notifyListeners);
}
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
const MaterialPage(
key: ValueKey('list'),
child: ListScreen(),
),
if (state.selectedItem != null)
MaterialPage(
key: ValueKey('details-${state.selectedItem}'),
child: DetailScreen(id: state.selectedItem!),
),
],
onPopPage: (route, result) {
if (!route.didPop(result)) return false;
state.clearSelection();
return true;
},
);
}
@override
Future<void> setNewRoutePath(AppState configuration) async {
state.selectedItem = configuration.selectedItem;
}
}
// In main():
// MaterialApp.router(
// routerDelegate: AppRouterDelegate(appState),
// routeInformationParser: AppRouteInformationParser(),
// );Tapping a list item calls state.select(id), which notifies the delegate, which rebuilds the Navigator with a second page. The back button pops the detail page, onPopPage clears the selection, and the stack collapses back to one page. The URL stays in sync on the web because the parser serializes the same state.
Why the Page keys matter
Flutter diffs the old and new page lists by key. If two pages share a key — or a page's key changes when it shouldn't — you get lost state, skipped animations, or the wrong page being popped. Use a stable ValueKey derived from the route's identity (ValueKey('details-$id')), never an index or a random value.
Limits and common mistakes
- Boilerplate. Two routes took a delegate, a parser, and a state class. For large apps this is why packages like go_router or auto_route exist — they wrap this same API.
- Forgetting
notifyListeners(). The most common bug: state changes, nothing rebuilds, and the screen appears frozen. Any mutation that should change the visible stack must notify. - Returning null or an empty pages list. The Navigator requires at least one page; guard against configurations that would produce none.
- Mixing push/pop with the declarative list. Calling
Navigator.push()on the same navigator adds a route the delegate doesn't know about; it won't survive the next rebuild. Pick one model per navigator. - Nested navigators. A shell with its own bottom-tab navigation needs its own RouterDelegate (or a plain inner Navigator). The outer Router will not see inner navigation events.
- Package support. Some older plugins assume Navigator 1.0. Check before migrating an app that depends on them.
Verifying it works
Run flutter run -d chrome and confirm: selecting an item updates the address bar to /details/..., the browser back button returns to the list, and pasting the detail URL into a fresh tab restores the detail screen. In Flutter DevTools' widget inspector, the Navigator's page list should match what your delegate's build produced. If the UI changes but the URL doesn't, the parser's restoreRouteInformation is the place to look.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.