All Guides
    Navigation & Routing
    Navigation
    Routing
    Navigator
    Advanced

    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:


    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/42 and 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 selectedBookId is 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 ValueKey configurations to each MaterialPage in 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 onPopPage callback.

    Best Practices

    • Use High-Level Wrapper Packages: Writing custom router delegates requires substantial boilerplate. For production apps, use wrapper packages like GoRouter or AutoRoute which 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:


    Summary

    • Navigator 2.0 (the Router API) provides declarative, state-driven routing.
    • RouteInformationParser parses raw URL path strings into structured state objects.
    • RouterDelegate listens for state changes and builds the active Navigator page list.
    • Always assign unique keys to page configurations and implement back button handling in onPopPage.

    Next Steps

    1. Previous Guide: Named Routes vs. Generated Routes
    2. Next Guide: Performance Optimization Tips
    3. Related Guide: GoRouter Setup Guide

    Ready to take the next step?

    Start our free Flutter course and build real apps today.

    Start Learning Now