Navigation 2 in Flutter: Mastering the Declarative Navigator 2.0 API
How the Router, RouterDelegate, RouteInformationParser, and Pages API actually fit together — a worked example, plus when to skip the plumbing and let go_router drive.
Published on • October 12, 2026
AI Assistant

Navigator.of(context).push(...) is the first navigation API most Flutter developers learn, and for an imperative stack poked at as the user taps things it works fine. It stops being enough the moment a URL has to mean something.
The navigation overview draws the line: small apps without complex deep linking can use Navigator, while “apps with specific deep linking and navigation requirements should also use the Router to correctly handle deep links on Android and iOS, and to stay in sync with the address bar when the app is running on the web.”
Where Navigator 1.0 runs out
The imperative API has three structural gaps:
- Deep links while running. With named routes, “when a new deep link is received by the platform, Flutter pushes a new
Routeonto the Navigator regardless of where the user currently is” — so a link to/settingslands on top of wherever the user already is. - Web URLs. Named routes don’t support the browser forward button, so the address bar, the history stack, and your internal state drift apart.
- State restoration. Rebuilding a back stack after the OS kills your process means serializing something, and
push()calls scattered across the tree don’t serialize themselves.
The Flutter docs are blunt about the workaround: “We don’t recommend using named routes for most applications. Instead, use go_router (or another routing package) or use Navigator with MaterialPageRoute.”
Navigator 2.0 answers by inverting the flow: instead of telling the framework to push a screen, you describe the stack you want and the framework reconciles it with reality.
The Router widget
Router is “the dispatcher for opening and closing pages of an application.” It listens for routing information from the OS — an initial route at startup, a new route from an intent, a back-button press — parses it into a configuration of type T, and “converts that data into Page objects that it passes to a Navigator.”
Its constructor takes routeInformationProvider, routeInformationParser, the required routerDelegate and backButtonDispatcher (plus restorationScopeId), with one asserted invariant: the first two “must both be provided or not provided.” In a Material app you don’t build Router yourself — MaterialApp.router takes those same pieces or a bundled routerConfig, in which case the individual delegates must all be null.
The backButtonDispatcher matters too: RootBackButtonDispatcher listens for popRoute notifications from the engine, while nested routers use a ChildBackButtonDispatcher chained to the parent’s — which is how back gestures reach the right level of a nested setup.
RouterDelegate: build and setNewRoutePath
The RouterDelegate is “the core piece of the Router widget” — both the builder that produces the navigating widget (typically a Navigator) and the sink that receives parsed route configurations.
Subclasses must implement exactly three methods:
build(context)— the widget tree for the current state.setNewRoutePath(configuration)— called when the OS reports a new route (deep link, intent, URL change).popRoute()— called when the system back button wants the current route gone; returns whether it was handled.
setInitialRoutePath (startup) and setRestoredRoutePath (state restoration, which “by default just calls setNewRoutePath”) come for free, and the delegate is a Listenable: call notifyListeners() after mutating route state and the Router rebuilds. currentConfiguration is the outbound half — return the current route and the Router reports it to the engine, which is how internal state becomes a URL.
RouteInformationParser: parse and restore
The RouteInformationParser translates between raw route information and your configuration type T: parseRouteInformation runs inbound (URL or initial route → T), restoreRouteInformation outbound (T → RouteInformation for the engine and browser). You must implement parseRouteInformation or parseRouteInformationWithDependencies, otherwise “a runtime error will be thrown”; the second adds a BuildContext for parsing that depends on inherited state such as a signed-in flag.
These methods are async, so the docs advise using SynchronousFuture when the work is really synchronous, and warn that implementations should not “perform extensive computation” — stale async results are discarded when a newer navigation arrives.
RouteInformationProvider and the engine
The RouteInformationProvider is the boundary with the platform: it exposes the current RouteInformation as a ValueListenable, notifies the Router when a new one arrives, and receives routerReportsNewRouteInformation when app state changes. You normally use PlatformRouteInformationProvider, which “propagates the platform route information changes” and reports back to the engine via SystemNavigator.routeInformationUpdated.
The Router docs explain the browser consequences: a changed location is a navigation event reported with replace = false so the browser creates a history entry, otherwise replace = true updates the current one. Router.navigate(context, callback) forces an entry even when the URL is unchanged (handy for storing scroll position in RouteInformation.state). Passing null for both provider and parser opts out of URL updates entirely — discouraged except for non-web apps or secondary nested routers.
The Pages API
This is where declarative navigation becomes tangible. Navigator.pages is “the list of pages with which to populate the history.” Pages become routes through Page.createRoute, and when the list changes “the new list is compared to the previous list and the set of routes is updated accordingly” — a diff, not a sequence of commands.
Routes pushed with Navigator.push belong to no page; those are pageless routes, removed along with the page below them. So page-backed routes are deep-linkable and pageless ones are not — and per the navigation overview, “you can’t prevent navigation from page-backed screens using PopScope or the deprecated WillPopScope”. Interception has to live in your routing layer.
Removing pages is where the two callback generations meet:
onDidRemovePage— “Called when theRouteassociated with the givenPagehas been removed.” Your callback drops that page from the list; if it is still present at the next update it is treated as a new page.onPopPage— deprecated after v3.16.0-17.0.pre: “UseonDidRemovePageinstead.”
A minimal parse/build cycle
Three moving parts, one for each direction of the loop:
sealed class AppRoute {}
class HomeRoute extends AppRoute {}
class ArticleRoute extends AppRoute {
ArticleRoute(this.slug);
final String slug;
}
class AppRouterDelegate extends RouterDelegate<AppRoute> with ChangeNotifier {
AppRoute _route = HomeRoute();
@override
AppRoute? get currentConfiguration => _route;
@override
Future<void> setNewRoutePath(AppRoute configuration) async {
_route = configuration;
}
@override
Future<bool> popRoute() async {
if (_route is! ArticleRoute) return false;
_route = HomeRoute();
notifyListeners();
return true;
}
@override
Widget build(BuildContext context) {
final route = _route;
return Navigator(
pages: [
MaterialPage(
key: const ValueKey('home'),
child: HomeScreen(
onOpen: (slug) {
_route = ArticleRoute(slug);
notifyListeners();
},
),
),
if (route is ArticleRoute)
MaterialPage(
key: ValueKey('article-${route.slug}'),
child: ArticleScreen(slug: route.slug),
),
],
onDidRemovePage: (page) {
_route = HomeRoute();
notifyListeners();
},
);
}
}
class AppRouteParser extends RouteInformationParser<AppRoute> {
@override
Future<AppRoute> parseRouteInformation(RouteInformation info) async {
final segments = info.uri.pathSegments;
if (segments.length >= 2 && segments.first == 'article') {
return ArticleRoute(segments[1]);
}
return HomeRoute();
}
@override
RouteInformation? restoreRouteInformation(AppRoute configuration) {
return switch (configuration) {
ArticleRoute(:final slug) => RouteInformation(uri: Uri(path: '/article/$slug')),
_ => RouteInformation(uri: Uri(path: '/')),
};
}
}
Wiring it up:
MaterialApp.router(
routeInformationProvider: PlatformRouteInformationProvider(
initialRouteInformation: RouteInformation(uri: Uri(path: '/')),
),
routeInformationParser: AppRouteParser(),
routerDelegate: AppRouterDelegate(),
);
The cycle: an incoming URL is parsed into AppRoute → setNewRoutePath stores it and notifies → build diffs a new pages list → currentConfiguration plus restoreRouteInformation write the URL back. State changes in-app run the same loop in reverse, so the address bar is never lying.
Deep links and the web
The deep linking guide shows why the Router path behaves better. On Android the app receives an initialRoute containing the link path, which the parser feeds to RouterDelegate.setNewRoutePath, “which configures the Navigator with the corresponding Pages”; if the app is already running, “the Navigator is configured with a new set of Pages” — a replacement, not an ever-growing stack. Under the imperative Navigator, the same event just calls pushRoute.
On the web, route paths work with no extra setup; by default the hash fragment is read (/#/path/to/screen). The URL strategies page covers clean paths: add flutter_web_plugins as an SDK dependency, call usePathUrlStrategy() before runApp, and have your server rewrite requests to index.html.
go_router: the same machine, already assembled
Everything above is what go_router implements for you: pub.dev describes it as “A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more”, and the readme says it “uses the Router API to provide a convenient, url-based API”.
It is the ecosystem default: published by the flutter.dev verified publisher, a Flutter Favorite, and showing 4.7M weekly downloads on pub.dev. You get path and query parameter templates (user/:id), redirection guards for auth flows, ShellRoute for a persistent bottom bar over multiple navigators, and backwards compatibility with the imperative Navigator API — including push on top of a declarative stack. The roadmap calls the package “considered feature-complete”, with the team focused on bug fixes. The official docs point the same way, recommending go_router instead of named routes for “a web app that uses direct links to each screen, or an app with multiple Navigator widgets.”
When to hand-roll Navigator 2
Reach for the raw Router API when the package model doesn’t expose what you need: a bespoke URL grammar, a nested tree where several levels each own their parsing, a URL-less app whose state machine must round-trip through restoration, or a routing component you’re publishing as part of a design system. It is also the fastest way to understand what go_router does underneath when you hit one of its edge cases.
For a product with screens, parameters, redirects, and deep links, use go_router — and keep this post as the map of the machinery underneath it.
Summary
Navigator 2.0 is a different contract: you own the state, the framework derives the stack, and the URL falls out of currentConfiguration plus restoreRouteInformation instead of being tracked separately. RouterDelegate builds and accepts state, RouteInformationParser converts it to and from URLs, RouteInformationProvider talks to the engine, and the Pages list makes the stack a diffable value. Learn it once — either to build on it, or to know exactly what go_router is doing for you.