Navigator 2.0 Deep Dive
Published April 2026
Introduction
In mobile application development, organizing screen navigation transitions is a fundamental task. In early versions of Flutter, this stack management was imperatively handled via Navigator 1.0 (invoking Navigator.push and Navigator.pop). While this model is straightforward, it is difficult to maintain in complex web applications that need to synchronize URL states, handle mobile deep linking, or declare route history stacks.
To solve these requirements, Flutter introduced Navigator 2.0 (also known as the Router API). Rather than imperatively pushing routes, Navigator 2.0 makes navigation declarative.
In the declarative model, the active page stack is a direct representation of your application's state. When the state changes, the router automatically recalculates the page stack, updating the active screen and the browser's address bar. In this guide, you will learn the architecture of native declarative routing, build a custom RouterDelegate and RouteInformationParser, and manage a state-driven page stack.
Prerequisites
Before implementing Navigator 2.0, ensure you have:
- Read our Named Routes vs. Generated Routes guide.
- Read our GoRouter Setup Guide.
- A solid understanding of state management patterns and Dart stream utilities.
Core Explanation: The Router API Architecture
The native Navigator 2.0 engine is built on three core components: the RouteInformationParser, the RouterDelegate, and the RouteInformationProvider.
┌──────────────┐ 1. Parses URL path string ┌────────────────────────┐
│ Browser URL │──────────────────────────────►│ RouteInformationParser │
└──────────────┘ └───────────┬────────────┘
│ 2. Produces state object
▼
┌──────────────┐ 4. Renders active screens ┌────────────────────────┐
│ Navigator │◄──────────────────────────────┤ RouterDelegate │
│ (Page Stack) │ │(Builds page configurations)
└──────────────┘ └────────────────────────┘
1. RouteInformationProvider
This component acts as the operating system gateway. It listens to the platform's address bar (on web) or deep links (on mobile) and forwards the raw route location details to the parser.
2. RouteInformationParser (URL to State)
The RouteInformationParser takes the raw path string and parses it into a user-defined Route Configuration State object (like a custom class representing the active page).
- Web-sync: When a user enters
https://myapp.com/book/42, the parser extracts the string/book/42and parses it into a structured configuration:BookRouteState(selectedBookId: 42).
3. RouterDelegate (State to Page Stack)
The RouterDelegate is the heart of Navigator 2.0. It listens to state changes (both from the parser and from the app's internal logic) and builds the Navigator page stack:
- State evaluation: If
selectedBookIdis null, the delegate outputs a list containing only the home screen page:[MaterialPage(child: HomeScreen())]. - State update: If the user selects a book, the state updates and the delegate builds a stack containing both the home and detail screens:
[MaterialPage(child: HomeScreen()), MaterialPage(child: DetailsScreen(bookId: 42))].
Practical Example: A Declarative Page Stack
Let's build a clean, compiler-ready declarative routing configuration using a custom state object and native Router components.
import 'package:flutter/material.dart';
void main() {
runApp(const MyAppNav2());
}
// ==========================================
// CUSTOM NAVIGATION STATE (Route Configuration)
// ==========================================
class AppRouteState {
final String? selectedItemId;
final bool isUnknown;
AppRouteState({this.selectedItemId, this.isUnknown = false});
factory AppRouteState.home() => AppRouteState();
factory AppRouteState.details(String id) => AppRouteState(selectedItemId: id);
factory AppRouteState.unknown() => AppRouteState(isUnknown: true);
bool get isHomeScreen => selectedItemId == null && !isUnknown;
bool get isDetailsScreen => selectedItemId != null && !isUnknown;
}
// ==========================================
// ROUTE INFORMATION PARSER
// ==========================================
class AppRouteInformationParser extends RouteInformationParser<AppRouteState> {
@override
Future<AppRouteState> parseRouteInformation(RouteInformation routeInformation) async {
final uri = Uri.parse(routeInformation.location ?? '/');
// Handle home path "/"
if (uri.pathSegments.isEmpty) {
return AppRouteState.home();
}
// Handle details path "/item/:id"
if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'item') {
final id = uri.pathSegments[1];
return AppRouteState.details(id);
}
// Fallback for unknown paths
return AppRouteState.unknown();
}
@override
RouteInformation? restoreRouteInformation(AppRouteState configuration) {
if (configuration.isUnknown) {
return const RouteInformation(location: '/404');
}
if (configuration.isDetailsScreen) {
return RouteInformation(location: '/item/${configuration.selectedItemId}');
}
return const RouteInformation(location: '/');
}
}
// ==========================================
// ROUTER DELEGATE
// ==========================================
class AppRouterDelegate extends RouterDelegate<AppRouteState>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRouteState> {
@override
final GlobalKey<NavigatorState> navigatorKey;
// The active state of our router
String? _selectedItemId;
bool _isUnknown = false;
AppRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();
@override
AppRouteState get currentConfiguration {
if (_isUnknown) return AppRouteState.unknown();
if (_selectedItemId != null) return AppRouteState.details(_selectedItemId!);
return AppRouteState.home();
}
// Update internal state when the parser restores configuration
@override
Future<void> setNewRoutePath(AppRouteState configuration) async {
if (configuration.isUnknown) {
_isUnknown = true;
_selectedItemId = null;
return;
}
if (configuration.isDetailsScreen) {
_selectedItemId = configuration.selectedItemId;
_isUnknown = false;
return;
}
_selectedItemId = null;
_isUnknown = false;
}
void selectItem(String? itemId) {
_selectedItemId = itemId;
_isUnknown = false;
notifyListeners(); // Rebuilds the navigator stack
}
void setUnknown() {
_isUnknown = true;
_selectedItemId = null;
notifyListeners();
}
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
// 1. Home Screen (Always present in the stack)
MaterialPage(
key: const ValueKey('HomeScreenPage'),
child: CatalogScreen(
onItemTapped: (id) => selectItem(id),
),
),
// 2. Details Screen (Pushed on top if an item is selected)
if (_selectedItemId != null)
MaterialPage(
key: ValueKey('DetailsScreenPage-$_selectedItemId'),
child: DetailViewScreen(
itemId: _selectedItemId!,
onBack: () => selectItem(null),
),
),
// 3. Fallback 404 Error Screen
if (_isUnknown)
const MaterialPage(
key: ValueKey('ErrorScreenPage'),
child: RouteErrorScreen(),
),
],
onPopPage: (route, result) {
// Handle physical back button pops
if (!route.didPop(result)) {
return false;
}
// Reset state when detail screen is popped
if (_selectedItemId != null) {
selectItem(null);
} else if (_isUnknown) {
_isUnknown = false;
notifyListeners();
}
return true;
},
);
}
}
// ==========================================
// APP ROOT WIDGET
// ==========================================
class MyAppNav2 extends StatefulWidget {
const MyAppNav2({super.key});
@override
State<MyAppNav2> createState() => _MyAppNav2State();
}
class _MyAppNav2State extends State<MyAppNav2> {
final _parser = AppRouteInformationParser();
final _delegate = AppRouterDelegate();
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Navigator 2.0 Example',
debugShowCheckedModeBanner: false,
routeInformationParser: _parser,
routerDelegate: _delegate,
);
}
}
// ==========================================
// SCREENS INDEX BUILD
// ==========================================
class CatalogScreen extends StatelessWidget {
final ValueChanged<String> onItemTapped;
const CatalogScreen({
super.key,
required this.onItemTapped,
});
@override
Widget build(BuildContext context) {
final itemsList = ['Item-A', 'Item-B', 'Item-C'];
return Scaffold(
appBar: AppBar(title: const Text('Catalog Directory')),
body: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: itemsList.length,
itemBuilder: (context, index) {
final id = itemsList[index];
return Card(
child: ListTile(
title: Text('Product $id'),
trailing: const Icon(Icons.chevron_right),
onTap: () => onItemTapped(id),
),
);
},
),
);
}
}
class DetailViewScreen extends StatelessWidget {
final String itemId;
final VoidCallback onBack;
const DetailViewScreen({
super.key,
required this.itemId,
required this.onBack,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Details: $itemId'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
),
body: Center(
child: Text(
'Product Details for $itemId',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
);
}
}
class RouteErrorScreen extends StatelessWidget {
const RouteErrorScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('404 Page')),
body: const Center(
child: Text('Requested item could not be found.'),
),
);
}
}
Common Mistakes
1. Imperative Pushing on Declarative Stacks
Using Navigator.push in widgets when your app is configured with Navigator 2.0 will bypass the router delegate, causing the URL state to desynchronize.
- The Fix: Update your navigation state object and trigger the router delegate to rebuild the page list.
2. Missing Key Definitions on Page Blocks
Failing to declare unique ValueKey objects on items inside the pages list prevents Flutter from identifying page state updates, leading to rendering errors during transitions.
- The Fix: Always assign unique
ValueKeyconfigurations to eachMaterialPagein the stack.
3. Neglecting Back Button Handling (onPopPage)
If your onPopPage callback returns false or does not update the navigation state, pressing the system back button will close the entire application instead of popping the active screen.
- The Fix: Implement clean pop checks and reset state variables in the
onPopPagecallback.
Best Practices
- Use High-Level Wrapper Packages: Writing custom router delegates requires substantial boilerplate. For production apps, use wrapper packages like
GoRouterorAutoRoutewhich implement the Router API internally. - Decouple Navigation State: Keep your navigation state class isolated from your UI code. This makes it easier to write unit tests for your routing logic.
- Always Define a Fallback Route: Always configure a default fallback path in your router to handle incorrect URLs or deep links.
Related Concepts
Continue mastering routing architecture in Flutter by exploring these guides:
- Understanding Widgets in Flutter: Learn the difference between layouts and the rendering layer.
- Named Routes vs. Generated Routes: Learn about imperative routing configurations in Flutter.
- GoRouter Setup Guide: Learn about declarative navigation design patterns.
Summary
- Navigator 2.0 (the Router API) provides declarative, state-driven routing.
RouteInformationParserparses raw URL path strings into structured state objects.RouterDelegatelistens for state changes and builds the activeNavigatorpage list.- Always assign unique keys to page configurations and implement back button handling in
onPopPage.
Next Steps
- Previous Guide: Named Routes vs. Generated Routes
- Next Guide: Performance Optimization Tips
- Related Guide: GoRouter Setup Guide
Ready to take the next step?
Start our free Flutter course and build real apps today.
Start Learning Now