Understanding Flutter Navigator 2.0: Declarative Routing for Reliable Deep Links
Learn how Flutter Navigator 2.0’s declarative Pages API solves deep linking and state‑sync problems, with a minimal code example, trade‑offs, and migration guidance.
29 Oct 2025, 12:04 UTC

The problem with imperative navigation
In Flutter’s original Navigator (Navigator.push and Navigator.pop) the navigation stack is changed imperatively. When a user follows a deep link, uses the system back button, or the app is restored after being killed, the imperative stack does not automatically reflect the current URL or application state. This mismatch can lead to screens appearing out of sync, lost state, or duplicated routes.
How Navigator 2.0 changes the model
Navigator 2.0 introduces a declarative approach: you describe the desired navigation stack as a function of the app’s state, and the framework ensures the UI matches that description. The key pieces are:
Router– widgets that listen to route information and rebuild when it changes.RouteInformationParser– converts raw location data (e.g., a URL string) into a customRouteInformationobject.RouterDelegate– holds the app’s state, builds a list ofPageobjects that represent the desired stack, and notifies the router when that list changes.Page– an immutable description of a route (similar to the oldRouteSettings).
When the system back button is pressed, the router asks the delegate for the previous page list; when a deep link arrives, the parser produces a new location and the delegate rebuilds the page list accordingly. This keeps the UI, the URL, and the internal state in sync.
Worked example: simple home/detail navigation
The following minimal example shows a Router that handles two paths: /home and detail/:id. Tapping a button on the home page pushes a detail page with an identifier; the back button or a swipe gesture returns to home.
import 'package:flutter/material.dart';
void main() => runApp(const Navigator2DemoApp());
class Navigator2DemoApp extends StatelessWidget {
const Navigator2DemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routeInformationParser: _SimpleRouteInformationParser(),
routerDelegate: _SimpleRouterDelegate(),
);
}
}
// Parses a string like "/detail/42" into a custom object.
class _SimpleRouteInformationParser extends RouteInformationParser {
@override
Future parseRouteInformation(RouteInformation routeInformation) async {
return routeInformation.location ?? '/home';
}
@override
RouteInformation restoreRouteInformation(String location) {
return RouteInformation(location: location);
}
}
// Holds the app state and builds the page list.
class _SimpleRouterDelegate extends RouterDelegate
with ChangeNotifier, PopNavigatorRouterDelegateMixin {
String _currentLocation = '/home';
String? _detailId;
@override
Widget build(BuildContext context) {
final List pages = [];
pages.add(const MaterialPage(key: ValueKey('home'), child: HomeScreen()));
if (_detailId != null) {
pages.add(MaterialPage(
key: ValueKey('detail-$_detailId'),
child: DetailScreen(id: _detailId!),
));
}
return Navigator(
pages: pages,
onPopPage: (route, result) {
if (!route.didPop(result)) return false;
_detailId = null;
_currentLocation = '/home';
notifyListeners();
return true;
},
);
}
@override
Future setNewRoutePattern(String location) async {
if (location.startsWith('/detail/')) {
final id = location.substring('/detail/'.length);
_detailId = id;
_currentLocation = location;
} else {
_detailId = null;
_currentLocation = '/home';
}
notifyListeners();
}
@override
String get currentConfiguration => _currentLocation;
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Center(
child: ElevatedButton(
onPressed: () {
// Imperative push still works inside a Navigator 2.0 app,
// but we update the delegate to reflect the new location.
final delegate = Router.of(context).routerDelegate as _SimpleRouterDelegate;
delegate._detailId = '42';
delegate._currentLocation = '/detail/42';
delegate.notifyListeners();
},
child: const Text('Go to Detail 42'),
),
),
);
}
}
class DetailScreen extends StatelessWidget {
final String id;
const DetailScreen({required this.id, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Detail $id')),
body: Center(child: Text('You are viewing item $id')),
);
}
}
Explanation of the flow:
- The app starts at
/home; the delegate’s_detailIdis null, so only the home page is in the page list. - When the button is pressed, we update the delegate’s state to reflect
/detail/42and callnotifyListeners. The router rebuilds, the delegate’sbuildmethod adds a secondMaterialPagefor the detail screen, and the Navigator shows it. - Pressing the system back button triggers
onPopPage. We reset_detailIdand the location, notify listeners, and the router removes the detail page, returning to home.
Trade‑offs and practical considerations
Navigator 2.0 removes the need to manually keep the URL and the navigation stack in sync, which is especially valuable for apps that support deep linking, web navigation, or state restoration. However, the declarative model introduces extra boilerplate:
- You must implement a parser and a delegate, even for simple navigation.
- Understanding the delegate lifecycle (
setNewRoutePattern,notifyListeners,currentConfiguration) is essential; mistakes can cause the UI to fall out of sync with the route information. - Migrating an existing codebase that heavily uses
Navigator.pushrequires replacing those calls with state updates in the delegate, or wrapping the old navigator inside aRouterwhile you migrate screen‑by‑screen.
Because of this overhead, many teams adopt Navigator 2.0 for new features or whole modules rather than attempting a piecemeal migration.
Actionable next steps
If you are starting a new Flutter project, consider using Navigator 2.0 from the outset:
- Define the routes your app needs and create a simple data class to represent the location.
- Implement a minimal
RouteInformationParserthat converts strings to that class. - Write a
RouterDelegatethat holds your navigation state (e.g., a current tab, selected item IDs) and builds the appropriate list ofPageobjects. - Plug the parser and delegate into
MaterialApp.router. - Test by navigating programmatically, using the device back button, and entering a deep link via
adb shell am start -W -a android.intent.action.VIEW -d "myapp://detail/7"(adjust the scheme to match your app). Verify that the UI updates and that hot‑restore preserves the shown screen.
By treating the navigation stack as a projection of your app’s state, you gain reliable deep linking, predictable back‑button behavior, and easier state restoration—all without manually juggling Navigator.push and pop calls.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.