Skip to content

Navigator & Routes

Navigator is a widget that manages a stack of routes. A route is a screen. The route on top of the stack is what the user sees. Navigation is just two stack operations: push a new route on top, or pop the top route off to reveal the one beneath.

flowchart TB
  subgraph stack["route stack"]
    top["DetailScreen  (visible, top)"]
    mid["ListScreen"]
    bottom["HomeScreen  (bottom)"]
  end
  top -->|pop| mid
  mid -->|push DetailScreen| top
Navigator is a stack of routes

The hardware/software back button, the app-bar back arrow, and a swipe-back gesture all do the same thing: pop the top route.

push, pop, pushReplacement, pushNamedAndRemoveUntil — proof

Section titled “push, pop, pushReplacement, pushNamedAndRemoveUntil — proof”

You get the Navigator from the current BuildContext with Navigator.of(context), then call one of four stack operations. push and pop grow/shrink the stack by one; pushReplacement swaps the top entry for a new one without changing the depth below it; pushNamedAndRemoveUntil pushes a new named route and pops everything below it that fails a predicate — (route) => false clears the whole stack, the standard “log out” / “onboarding done” pattern.

lib/stack_screens.dart
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (context) => const ListScreen()),
),
child: const Text('Home — go to List'),
),
),
);
}
}
class ListScreen extends StatelessWidget {
const ListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('List'),
TextButton(
onPressed: () async {
// push<int> + await: the pushed screen's pop(value) resolves this Future.
final picked = await Navigator.of(context).push<int>(
MaterialPageRoute<int>(builder: (context) => const DetailScreen()),
);
lastPoppedResult = picked;
},
child: const Text('List — go to Detail'),
),
TextButton(
// pushReplacement: swaps List for Settings. Home, below List, is untouched.
onPressed: () => Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(builder: (context) => const SettingsScreen()),
),
child: const Text('List — replace with Settings'),
),
],
),
),
);
}
}
class DetailScreen extends StatelessWidget {
const DetailScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: TextButton(
onPressed: () => Navigator.of(context).pop(42),
child: const Text('Detail — pop with 42'),
),
),
);
}
}
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) =>
const Scaffold(body: Center(child: Text('Settings')));
}
/// A plain top-level var, not app state — just a hook so the test below can
/// read the value that `pop(42)` sent back through the awaited `push<int>`.
int? lastPoppedResult;
test/stack_screens_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/stack_screens.dart';
void main() {
testWidgets(
'push/pop moves the stack and returns a value; pushReplacement swaps the top without changing depth below it',
(tester) async {
lastPoppedResult = null;
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
expect(find.text('Home — go to List'), findsOneWidget);
await tester.tap(find.text('Home — go to List'));
await tester.pumpAndSettle();
expect(find.text('List'), findsOneWidget);
await tester.tap(find.text('List — go to Detail'));
await tester.pumpAndSettle();
expect(find.text('Detail — pop with 42'), findsOneWidget);
await tester.tap(find.text('Detail — pop with 42'));
await tester.pumpAndSettle();
expect(find.text('List'), findsOneWidget, reason: 'pop reveals the screen below on the stack');
expect(lastPoppedResult, 42, reason: 'the value passed to pop() travels back through the awaited push<int>()');
await tester.tap(find.text('List — replace with Settings'));
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
expect(find.text('List'), findsNothing, reason: 'pushReplacement removed List — it did not stay stacked underneath');
final navigator = tester.state<NavigatorState>(find.byType(Navigator).first);
expect(navigator.canPop(), isTrue, reason: 'Home is still below Settings — pushReplacement only swapped the top entry');
navigator.pop();
await tester.pumpAndSettle();
expect(find.text('Home — go to List'), findsOneWidget, reason: 'popping Settings reveals Home directly — List never reappears, because it was replaced, not pushed over');
},
);
}
✓ push/pop moves the stack and returns a value; pushReplacement swaps the top without changing depth below it
00:00 +1: All tests passed!

pushNamedAndRemoveUntil needs named routes to demonstrate, so it gets its own small app:

lib/remove_until_app.dart
import 'package:flutter/material.dart';
class RemoveUntilApp extends StatelessWidget {
const RemoveUntilApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => const NamedHome(),
'/settings': (context) => const NamedSettings(),
'/login': (context) => const NamedLogin(),
},
);
}
}
class NamedHome extends StatelessWidget {
const NamedHome({super.key});
@override
Widget build(BuildContext context) => Scaffold(
body: TextButton(
onPressed: () => Navigator.of(context).pushNamed('/settings'),
child: const Text('Home'),
),
);
}
class NamedSettings extends StatelessWidget {
const NamedSettings({super.key});
@override
Widget build(BuildContext context) => Scaffold(
body: TextButton(
// (route) => false rejects every existing route, so ALL of them —
// including Home — are removed, not just the ones "above" /login.
onPressed: () => Navigator.of(context)
.pushNamedAndRemoveUntil('/login', (route) => false),
child: const Text('Settings — log out'),
),
);
}
class NamedLogin extends StatelessWidget {
const NamedLogin({super.key});
@override
Widget build(BuildContext context) => const Scaffold(body: Text('Login'));
}
test/remove_until_app_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/remove_until_app.dart';
void main() {
testWidgets('pushNamedAndRemoveUntil((route) => false) clears the ENTIRE stack, not just routes above the target', (tester) async {
await tester.pumpWidget(const RemoveUntilApp());
await tester.tap(find.text('Home'));
await tester.pumpAndSettle();
expect(find.text('Settings — log out'), findsOneWidget);
await tester.tap(find.text('Settings — log out'));
await tester.pumpAndSettle();
expect(find.text('Login'), findsOneWidget);
final navigator = tester.state<NavigatorState>(find.byType(Navigator).first);
expect(navigator.canPop(), isFalse, reason: 'Home and Settings are both gone — Login is the only entry left on the stack');
});
}
✓ pushNamedAndRemoveUntil((route) => false) clears the ENTIRE stack, not just routes above the target
00:00 +1: All tests passed!

Intercepting the back gesture used to be WillPopScope. It is deprecated — quoting the annotation directly from the installed Flutter 3.47.5 SDK (packages/flutter/lib/src/widgets/will_pop_scope.dart):

@Deprecated('Use PopScope instead. The Android predictive back feature will not work with WillPopScope. This feature was deprecated after v3.12.0-1.0.pre.')

The replacement, PopScope<T>, has two members that matter (again quoting the installed SDK, packages/flutter/lib/src/widgets/pop_scope.dart):

  • canPop (bool, default true) — if false, a system back gesture (and predictive back’s preview) is disabled for this route.
  • onPopInvokedWithResult (PopInvokedWithResultCallback<T>?) — called after the pop was handled, with didPop (did it actually happen) and the pop result. It still fires with didPop: false when canPop: false blocked the attempt — this is not a “veto” callback, canPop is what vetoes.

There used to be an onPopInvoked (no Result) — it is itself deprecated on PopScope, quoting the same file: @Deprecated('Use onPopInvokedWithResult instead. This feature was deprecated after v3.22.0-12.0.pre.').

lib/pop_scope_demo.dart
import 'package:flutter/material.dart';
/// Records `onPopInvokedWithResult` calls so the test can assert on them —
/// a top-level list plays the same role `lastPoppedResult` played above.
final popScopeEvents = <String>[];
class PopScopeHost extends StatelessWidget {
const PopScopeHost({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (context) => const PopScopeDemo()),
),
child: const Text('Root — push guarded screen'),
),
);
}
}
class PopScopeDemo extends StatefulWidget {
const PopScopeDemo({super.key});
@override
State<PopScopeDemo> createState() => _PopScopeDemoState();
}
class _PopScopeDemoState extends State<PopScopeDemo> {
bool guardEnabled = true;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !guardEnabled,
onPopInvokedWithResult: (bool didPop, Object? result) {
popScopeEvents.add(didPop ? 'popped' : 'blocked');
},
child: Scaffold(
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Guarded screen'),
TextButton(
onPressed: () => setState(() => guardEnabled = false),
child: const Text('Disable guard'),
),
],
),
),
);
}
}
test/pop_scope_demo_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/pop_scope_demo.dart';
void main() {
testWidgets('PopScope(canPop:false) blocks the pop and still fires onPopInvokedWithResult(false, ...); disabling the guard lets it through', (tester) async {
popScopeEvents.clear();
await tester.pumpWidget(const MaterialApp(home: PopScopeHost()));
await tester.tap(find.text('Root — push guarded screen'));
await tester.pumpAndSettle();
expect(find.text('Guarded screen'), findsOneWidget);
final navigator = tester.state<NavigatorState>(find.byType(Navigator).first);
navigator.maybePop();
await tester.pumpAndSettle();
expect(find.text('Guarded screen'), findsOneWidget, reason: 'canPop:false blocked the pop — still on the guarded screen');
expect(popScopeEvents, ['blocked'], reason: 'the callback still fires, with didPop:false — it reports, it does not itself veto');
await tester.tap(find.text('Disable guard'));
await tester.pump();
navigator.maybePop();
await tester.pumpAndSettle();
expect(find.text('Root — push guarded screen'), findsOneWidget, reason: 'guard disabled — the pop went through this time');
expect(popScopeEvents, ['blocked', 'popped']);
});
}
✓ PopScope(canPop:false) blocks the pop and still fires onPopInvokedWithResult(false, ...); disabling the guard lets it through
00:00 +1: All tests passed!

Since Flutter 3.38, the default page transition on TargetPlatform.android is PredictiveBackPageTransitionsBuilder — the real class name, verified directly against the installed SDK source (packages/flutter/lib/src/material/page_transitions_theme.dart, _defaultBuilders map: TargetPlatform.android: PredictiveBackPageTransitionsBuilder()). It follows the user’s back gesture and previews the destination screen underneath as they drag — you get it for free from MaterialPageRoute, no code change required, because the transition is read from PageTransitionsTheme.

On Android specifically, the OS-level predictive back preview (the frame that peels away to show the previous screen/home screen) additionally needs an opt-in flag, per docs.flutter.dev’s predictive back guide:

<!-- android/app/src/main/AndroidManifest.xml, inside <activity> -->
android:enableOnBackInvokedCallback="true"

and Android 14 (API 34)+ enables the system-level predictive back animation by default when that flag is set. PopScope — not the deprecated WillPopScope — is what makes a route’s own back-interception logic compatible with the gesture: the same docs page states plainly that “PopScope replaces the deprecated WillPopScope widget and supports predictive back gestures.”

Section titled “Navigator 2.0: Router, RouterDelegate, RouteInformationParser”

Everything above is Navigator 1.0 — imperative push/pop calls. Flutter also ships a declarative routing system, Navigator 2.0, built from three collaborating pieces (api.flutter.dev/flutter/widgets/Router-class.html):

  • Router — the dispatcher. It listens to the OS for routing information (a deep link, a browser URL, pushNamed) and hands it off.
  • RouteInformationParser — turns that raw routing information into your own typed app-state object.
  • RouterDelegate — takes that typed state and builds the actual Navigator (a list of Pages) it corresponds to; it also has to report state back when the Navigator itself pops something (e.g. a system back gesture), keeping the URL/OS routing state and the widget tree in sync both ways.
flowchart LR
  os["OS routing info
(deep link, browser URL)"] --> router["Router"]
  router --> parser["RouteInformationParser
(raw -> typed state)"]
  parser --> delegate["RouterDelegate
(typed state -> Navigator/Page list)"]
  delegate --> nav["Navigator"]
  nav -->|"pop reported back"| delegate
Navigator 2.0 pieces

This is real power (URL-driven navigation, exact control over the whole Page stack for state restoration and deep links) at the cost of real boilerplate — you write a RouteInformationParser and a RouterDelegate by hand, wire ChangeNotifier into the delegate to make it rebuild on state change, and reimplement things Navigator 1.0 gave you for free. This is exactly why the ecosystem doesn’t write RouterDelegate subclasses directly for typical apps: go_router’s own pub.dev description states it plainly — go_router “is a declarative routing package for Flutter that uses the Router API to provide a convenient, url-based API for navigating between different screens.” go_router is a RouterDelegate/RouteInformationParser pair, written once by the package so you configure it with a list of GoRoutes instead of hand-rolling the delegate — the next lesson is that API.

Section titled “Navigator.of(context) outside a Navigator — the real error, and rootNavigator: true”

Navigator.of(context) walks up the widget tree from context looking for the nearest Navigator ancestor. If there isn’t one, it throws — the real message, quoted from the installed SDK (packages/flutter/lib/src/widgets/navigator.dart). No extra app code is needed to prove it — the test calls Navigator.of() directly against a context that has no Navigator ancestor at all:

test/navigator_of_errors_test.dart
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Navigator.of(context) throws FlutterError with the real message when there is no Navigator ancestor', (tester) async {
late BuildContext capturedContext;
await tester.pumpWidget(Builder(builder: (context) {
capturedContext = context;
return const SizedBox.shrink();
}));
expect(
() => Navigator.of(capturedContext),
throwsA(
isA<FlutterError>().having(
(e) => e.toString(),
'message',
contains('Navigator operation requested with a context that does not include a Navigator.'),
),
),
);
});
}
✓ Navigator.of(context) throws FlutterError with the real message when there is no Navigator ancestor
00:00 +1: All tests passed!

The most common way this actually happens in real code is not “forgot MaterialApp” — it’s calling Navigator.of(context) with a context from above a route you just pushed (e.g. the context closed over by a callback defined before the push, instead of the pushed screen’s own context), or from inside a widget that is being built as part of the route transition itself, before that subtree is attached under any Navigator.

rootNavigator: true is for the opposite problem: too many Navigators to choose from. A widget can embed its own nested Navigator (a wizard, a nested tab’s own back-stack, the Navigator a showDialog builds internally) — by default Navigator.of(context) finds the nearest one, but sometimes you need the outermost one instead (to pop a full-screen dialog from deep inside its content, for instance).

lib/root_navigator_demo.dart
import 'package:flutter/material.dart';
/// A widget that embeds its OWN nested Navigator — the shape of an in-page
/// wizard, a nested tab's own back-stack, or what showDialog builds internally.
class RootNavigatorDemo extends StatelessWidget {
const RootNavigatorDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Navigator(
onGenerateRoute: (settings) => MaterialPageRoute(
builder: (context) => const Center(child: Text('Inner navigator route')),
),
),
);
}
}
test/root_navigator_demo_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/root_navigator_demo.dart';
void main() {
testWidgets('rootNavigator:true resolves to the OUTERMOST Navigator, not the nearest one', (tester) async {
await tester.pumpWidget(const MaterialApp(home: RootNavigatorDemo()));
await tester.pumpAndSettle();
final innerContext = tester.element(find.text('Inner navigator route'));
final nearest = Navigator.of(innerContext);
final root = Navigator.of(innerContext, rootNavigator: true);
expect(identical(nearest, root), isFalse, reason: 'two distinct NavigatorState instances answer the same context');
expect(
identical(root, tester.state<NavigatorState>(find.byType(Navigator).first)),
isTrue,
reason: 'rootNavigator:true walks all the way out — to MaterialApp\'s own Navigator, the first one built',
);
});
}
✓ rootNavigator:true resolves to the OUTERMOST Navigator, not the nearest one
00:00 +1: All tests passed!

Passing data forward and named routes vs onGenerateRoute

Section titled “Passing data forward and named routes vs onGenerateRoute”

The cleanest way to send data to the next screen is a plain constructor argument — no magic, no global state:

Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => DetailScreen(userId: 42),
),
);
class DetailScreen extends StatelessWidget {
const DetailScreen({super.key, required this.userId});
final int userId;
}

For a small app you can register named routes in MaterialApp.routes and navigate by string name — it centralizes the route table:

MaterialApp(
routes: {
'/': (context) => const HomeScreen(),
'/settings': (context) => const SettingsScreen(),
},
);
Navigator.of(context).pushNamed('/settings');

routes is a fixed map with no access to the pushed route’s arguments inside the builder signature itself. onGenerateRoute is the escape hatch: a function that receives the full RouteSettings (name and arguments) and returns whichever Route you build, including choosing the transition or falling back to a 404 screen for an unmatched name:

MaterialApp(
onGenerateRoute: (settings) {
if (settings.name == '/details') {
final id = settings.arguments as int;
return MaterialPageRoute(builder: (context) => DetailScreen(userId: id));
}
return MaterialPageRoute(builder: (context) => const NotFoundScreen());
},
);

Both are still string-keyed and untyped at the call site (settings.arguments as int is a runtime cast, not a compile-time guarantee) and neither has a first-class answer for a real URL-shaped deep link (/users/42/posts/7) — you’d hand-parse settings.name yourself. That gap is exactly why the team recommends go_router for anything beyond a handful of screens: it gives typed path parameters, nested/shell routes, and redirect-based guards on top of the same Router API described above, without you writing a RouterDelegate by hand — covered in the next lesson.

Think of Navigator as a List<Route> with two allowed mutations, push/pop, plus a handful of named conveniences built from them (pushReplacement = pop-then-push-in-one-frame at the top; pushNamedAndRemoveUntil = push, then pop everything that fails a predicate). Every route is an element in that list — pushReplacement never touches anything below the top, which is why Home survived pushReplacement’s swap above but not pushNamedAndRemoveUntil((route) => false)’s full clear. PopScope doesn’t sit “in” that list at all — it’s a widget inside the route’s own subtree that gets a say (canPop) in whether a pop request against that specific route is honored, and a report (onPopInvokedWithResult) either way.

ChoiceBenefitCost
Navigator 1.0 (push/pop, MaterialPageRoute)Minimal setup, easy to learn, fine for simple flowsNo URL support, deep links need manual parsing, stack state isn’t naturally serializable
Named routes (MaterialApp.routes)Central route table, short call sitesNo typed arguments, no per-route arguments access in the builder
onGenerateRouteFull control per route (arguments, transitions, 404 fallback)Still string-keyed, still a runtime cast for arguments, hand-rolled 404 handling
Navigator 2.0 (Router/RouterDelegate by hand)Full declarative control, true URL sync, precise Page stack for state restorationReal boilerplate — a RouterDelegate + RouteInformationParser you write and maintain yourself
go_router (next lesson)Router 2.0’s power with a GoRoute-list API, typed paths, redirect guardsOne more package version to track; still worth reading Navigator 1.0/2.0 to understand what it wraps
  • “WillPopScope still works fine, it’s just old.” It’s @Deprecated in the installed 3.47.5 SDK, and per the docs it structurally cannot support predictive back — the annotation says so directly: “The Android predictive back feature will not work with WillPopScope.”
  • “PopScope’s onPopInvokedWithResult can prevent the pop.” It can’t — it fires after the pop was already decided. canPop is the only thing that vetoes, proven above (popScopeEvents recorded 'blocked' from the callback while the screen never changed).
  • “pushReplacement clears the whole stack.” It swaps exactly one entry — the top. Everything below is untouched, proven above (canPop() stayed true, and popping Settings revealed Home directly).
  • “Named routes and onGenerateRoute give you type-safe arguments.” Both pass Object?/arguments through RouteSettings — every read is a runtime cast, not a compile-time guarantee.
  • “Navigator.of(context) always finds the same Navigator no matter which context you pass.” It finds the nearest one, and a widget can nest its own — proven above (nearest and root were provably distinct NavigatorState instances for the same context).

A very common AI-generated pattern: an async “save, then navigate back” helper that reads clean but holds a BuildContext across an await.

// AI-generated: "save then go back"
Future<void> saveAndClose(BuildContext context) async {
await saveToDatabase();
Navigator.of(context).pop();
}

Real flutter analyze output on this exact snippet, from this probe:

info • Don't use 'BuildContext's across async gaps. Try rewriting the code to not use
the 'BuildContext', or guard the use with a 'mounted' check
• lib/bug.dart:5:16 • use_build_context_synchronously

The widget that called saveAndClose may have been popped, unmounted, or replaced during the await saveToDatabase() — using its context after that is undefined-ish behavior (it can throw, or silently target a defunct element). The fix is the same mounted guard the state-management module proves for setState, applied here to Navigator.of(context) instead:

Future<void> saveAndClose(BuildContext context) async {
await saveToDatabase();
if (!context.mounted) return; // guard the BuildContext use after the await
Navigator.of(context).pop();
}

🐛 Spot the bug

// AI-generated: "guard the exit with a confirmation dialog"
class EditScreen extends StatelessWidget {
const EditScreen({super.key});
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
final leave = await showExitDialog(context);
return leave ?? false;
},
child: const Scaffold(body: Text('Editing...')),
);
}
}
Show the answer

This compiles (with a deprecation warning) but is wrong for two separate reasons. First, WillPopScope is @Deprecated since v3.12.0-1.0.pre — the annotation states directly that “the Android predictive back feature will not work” with it, so on Android 14+ with predictive back enabled the exit confirmation is silently skipped by the OS-level gesture preview, not intercepted. Second, even ignoring deprecation, onWillPop can only answer after the fact is already known (Future<bool>) — it cannot show a live preview mid-gesture, which is the entire point of predictive back. The fix is PopScope(canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; final leave = await showExitDialog(context); if (leave == true && context.mounted) Navigator.of(context).pop(); }) — canPop: false blocks the pop up front (compatible with predictive back’s preview), and the dialog only runs after a real pop attempt was already blocked.

What data structure does Navigator manage?
After pushReplacement swaps List for Settings on a stack that was [Home, List], what does Navigator.canPop() report, per the test in this lesson?
What is the real, current class name for the default Android page transition since Flutter 3.38 — verified against the installed SDK source?
What does PopScope's onPopInvokedWithResult callback actually do, proven by the widget test in this lesson?
A context has no Navigator ancestor at all. What happens when you call Navigator.of(context), per the real FlutterError proven in this lesson?
A widget embeds its own nested Navigator inside a MaterialApp. What does Navigator.of(context, rootNavigator: true) resolve to, per the identity-comparison test in this lesson?
Per the deprecation annotation quoted from the installed 3.47.5 SDK, why is WillPopScope specifically unsuitable, beyond just being old API?