Navigation & Routing

Nested Navigation with Navigator

15 min Lesson 7 of 14

Nested Navigation with Navigator

In Flutter, every application has a root Navigator — the top-level navigation stack that manages full-screen routes. However, many real-world apps contain sections (tabs, drawers, or dedicated sub-flows) that need their own independent history stack. Nested navigation solves this by embedding a second Navigator widget inside a part of the UI so that inner screens can push and pop independently without disturbing the top-level navigator.

A classic example is a BottomNavigationBar app where each tab maintains its own back-stack. When the user navigates deep into Tab A and then switches to Tab B, the Tab A stack is preserved exactly as they left it. Pressing the device back button only pops within the current tab's navigator, not at the root level.

Note: The Navigator widget is itself just a widget — you can place it anywhere in the tree. Each Navigator instance maintains its own History stack independently of every other Navigator in the widget tree.

Why Use Nested Navigators?

  • Preserved tab state: Each tab remembers its own screen stack when the user switches tabs.
  • Isolated back behavior: Back navigation inside a nested flow does not accidentally pop the root screen.
  • Scoped sub-flows: Onboarding, checkout, or wizard flows can live inside a nested Navigator and be dismissed as a unit when complete.
  • Cleaner architecture: Each section of the app owns its routing logic without polluting the global route table.

The Navigator Widget Hierarchy

When Flutter processes navigation, it walks up the widget tree looking for the nearest Navigator ancestor. If you embed a second Navigator, calls to Navigator.of(context) from within that subtree will resolve to the inner navigator unless you explicitly pass rootNavigator: true.

Basic Nested Navigator Setup (Tab Navigation)

import 'package:flutter/material.dart';

class MainShell extends StatefulWidget {
  const MainShell({super.key});

  @override
  State<MainShell> createState() => _MainShellState();
}

class _MainShellState extends State<MainShell> {
  int _currentIndex = 0;

  // One GlobalKey per tab so Flutter can keep each Navigator alive
  final List<GlobalKey<NavigatorState>> _navigatorKeys = [
    GlobalKey<NavigatorState>(),
    GlobalKey<NavigatorState>(),
    GlobalKey<NavigatorState>(),
  ];

  Widget _buildTabNavigator(int index) {
    return Navigator(
      key: _navigatorKeys[index],
      onGenerateRoute: (settings) {
        // Each tab starts at its own home screen
        return MaterialPageRoute(
          builder: (_) => _tabHome(index),
          settings: settings,
        );
      },
    );
  }

  Widget _tabHome(int index) {
    const titles = ['Home', 'Search', 'Profile'];
    return Scaffold(
      appBar: AppBar(title: Text(titles[index])),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // Pushes onto THIS tab's nested Navigator, not the root
            _navigatorKeys[index].currentState!.push(
              MaterialPageRoute(builder: (_) => const DetailScreen()),
            );
          },
          child: const Text('Go to Detail'),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(
        index: _currentIndex,
        children: List.generate(3, _buildTabNavigator),
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (i) => setState(() => _currentIndex = i),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    );
  }
}

class DetailScreen extends StatelessWidget {
  const DetailScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Detail')),
      body: const Center(child: Text('Detail Screen')),
    );
  }
}
Tip: Use IndexedStack instead of a plain Stack or Offstage to keep all tab navigators alive simultaneously. IndexedStack preserves each child's state while rendering only the active index, which is exactly what you need for persistent tab stacks.

Handling the Android Back Button

By default, the system back button (Android hardware back or predictive back gesture) is handled by the root navigator. To intercept it and pop the active tab's nested navigator first, wrap the Scaffold in a PopScope (Flutter 3.12+) or the older WillPopScope:

Intercepting Back Button for Nested Navigators

@override
Widget build(BuildContext context) {
  return PopScope(
    // canPop: false prevents root-level pop while we handle it
    canPop: false,
    onPopInvokedWithResult: (didPop, result) {
      if (didPop) return;
      final innerNav = _navigatorKeys[_currentIndex].currentState;
      if (innerNav != null && innerNav.canPop()) {
        // Pop the inner (tab) navigator first
        innerNav.pop();
      } else {
        // Nothing left in this tab — allow the system to handle
        // (e.g., exit the app if on the root route)
        Navigator.of(context).pop();
      }
    },
    child: Scaffold(
      body: IndexedStack(
        index: _currentIndex,
        children: List.generate(3, _buildTabNavigator),
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (i) => setState(() => _currentIndex = i),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    ),
  );
}
Warning: Do not use Navigator.of(context, rootNavigator: true) to push routes inside a tab. Doing so bypasses the nested navigator and pushes onto the root stack, which makes the new screen cover the entire shell (bottom bar disappears) and breaks the isolated back-stack behaviour you set up.

Accessing the Correct Navigator

Inside a widget that lives within a nested navigator's subtree, Navigator.of(context) resolves to the nearest ancestor — the inner navigator. This is usually what you want. Use rootNavigator: true only when you deliberately want to show a modal or a screen above the entire shell (e.g., a full-screen payment flow that covers everything).

  • Navigator.of(context) — nearest navigator (nested tab navigator inside tabs)
  • Navigator.of(context, rootNavigator: true) — the root navigator (covers entire app shell)
  • navigatorKey.currentState! — direct access via GlobalKey from anywhere

Summary

Nested navigation is an essential pattern for any Flutter app that has sections requiring independent history stacks. The key points are: embed a Navigator widget with a GlobalKey for each independent section, use IndexedStack to keep all sections alive, intercept the system back button with PopScope to pop the inner navigator first, and be deliberate about whether you push onto the inner or root navigator by choosing between Navigator.of(context) and Navigator.of(context, rootNavigator: true).