← Back to blog
Flutter Fundamentals · Part 8 of 9
July 22, 202610 min read

Navigation in Flutter: Navigator 1.0, push, pop & Named Routes

FlutterDart

Navigation in Flutter

This is Part 8 of the Flutter Fundamentals series. You can build screens; now let's move between them. Almost every app needs navigation — tap a list item to see details, open a settings page, push a checkout flow. Flutter's classic, imperative navigation API is Navigator 1.0, and it's built on one beautifully simple idea: a stack of screens.

(There's also a newer declarative Navigator 2.0 / the go_router package for deep linking and complex flows, but Navigator 1.0 is the foundation everyone starts with — and what you'll use most.)


The mental model: a stack of cards

The Navigator manages a stack of routes, where a route is just a screen/page. A stack is last-in-first-out, like a pile of index cards:

  • push a route → place a new card on top (it covers the screen).
  • pop a route → remove the top card, revealing the one beneath.
  push DetailScreen          pop
┌──────────────┐         ┌──────────────┐
│  Detail   ◄──┼── top   │  Home     ◄──┼── top (Detail removed)
├──────────────┤         └──────────────┘
│  Home        │
└──────────────┘

The back button (and iOS swipe-back) simply calls pop. That's the whole model — everything else is variations on push and pop.


Basic navigation: push and pop

Going forward with Navigator.push

To open a new screen, push a MaterialPageRoute (which gives a platform-appropriate slide/fade transition for free):

// On the first screen, when a button is tapped:
ElevatedButton(
  child: const Text('Open details'),
  onPressed: () {
    Navigator.push(
      context,
      MaterialPageRoute<void>(
        builder: (context) => const DetailScreen(),
      ),
    );
  },
)

Two pieces every push needs:

  • context — recall from Part 2/Part 4 that context is the Element. Navigator.push walks up the tree from this context to find the nearest Navigator (provided by MaterialApp). Use a context below the MaterialApp, or you'll get a "no Navigator" error.
  • MaterialPageRoute — wraps your destination widget and defines the transition. Its builder returns the screen.

Going back with Navigator.pop

To return to the previous screen, pop the current route off the stack:

// On the DetailScreen:
ElevatedButton(
  onPressed: () => Navigator.pop(context),
  child: const Text('Go back'),
)

You often don't even need this — a Scaffold with an AppBar shows an automatic back button that pops for you. But pop is there when you want to close a screen programmatically (e.g. after saving).

A complete two-screen app

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: FirstScreen()));

class FirstScreen extends StatelessWidget {
  const FirstScreen({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('First')),
      body: Center(
        child: ElevatedButton(
          child: const Text('Open'),
          onPressed: () => Navigator.push(
            context,
            MaterialPageRoute<void>(builder: (_) => const SecondScreen()),
          ),
        ),
      ),
    );
  }
}

class SecondScreen extends StatelessWidget {
  const SecondScreen({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Second')), // auto back button
      body: Center(
        child: ElevatedButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('Back'),
        ),
      ),
    );
  }
}

Passing data to a new screen

Because a route is just a widget, the cleanest way to send data forward is via the destination's constructor — ordinary Dart, fully type-safe:

Navigator.push(
  context,
  MaterialPageRoute<void>(
    builder: (_) => DetailScreen(product: selectedProduct), // pass data in
  ),
);

class DetailScreen extends StatelessWidget {
  final Product product;
  const DetailScreen({super.key, required this.product});
  // ... use widget.product / product
}

No magic, no serialization — you're constructing a widget and handing it data. This is why "everything is a widget" (Part 1) keeps paying off.


Returning data from a screen

Here's the elegant part: Navigator.push returns a Future that completes when the pushed route is popped — and pop can carry a result. This is the async/await from the Dart series in action:

// Caller awaits the result of the screen it opened:
Future<void> _pickColor() async {
  final selected = await Navigator.push<String>(
    context,
    MaterialPageRoute<String>(builder: (_) => const ColorPickerScreen()),
  );
  if (!mounted) return;          // guard after await (Part 3 / async series)
  if (selected != null) {
    setState(() => _color = selected);
  }
}
// The picker returns a value by passing it to pop:
onTap: () => Navigator.pop(context, 'green'); // result flows back to the awaiter

The flow: push returns a Future; the user interacts; pop(context, value) completes that future with value. If the user backs out without choosing, the future completes with null (so always handle the null case). This push-returns-a-future pattern is how you implement "pick something on another screen and bring it back."


Named routes

Pushing MaterialPageRoutes inline works great, but in a larger app you may prefer named routes — string identifiers declared centrally, so navigation calls are short and routes live in one place.

Declare the routes table

MaterialApp(
  initialRoute: '/',
  routes: {
    '/': (context) => const HomeScreen(),
    '/details': (context) => const DetailScreen(),
    '/settings': (context) => const SettingsScreen(),
  },
)

Use either home: or routes['/'] for the first screen — not both (that throws). The routes map's '/' entry is your home.

Navigate by name

Navigator.pushNamed(context, '/details');

pop works exactly the same. To pass arguments with named routes, use the arguments parameter and read them from ModalRoute:

Navigator.pushNamed(context, '/details', arguments: product);

// In DetailScreen.build:
final product = ModalRoute.of(context)!.settings.arguments as Product;

Note the trade-off: named-route arguments are untyped (Object? you cast), which is less safe than passing typed data through a constructor. That's one reason many teams prefer constructor-passing or a typed router like go_router.

onGenerateRoute — dynamic / parameterized routes

For routes that need logic (parsing an ID out of a path, guarding access, custom transitions), use onGenerateRoute:

MaterialApp(
  onGenerateRoute: (settings) {
    if (settings.name == '/product') {
      final id = settings.arguments as String;
      return MaterialPageRoute(builder: (_) => ProductScreen(id: id));
    }
    return MaterialPageRoute(builder: (_) => const NotFoundScreen());
  },
)

onGenerateRoute is the central place to handle unknown routes, deep links, and routes that take parameters.


Named vs inline routes: which to use?

| | Inline push(MaterialPageRoute) | Named pushNamed | | --- | --- | --- | | Route defined | at the call site | centrally in routes/onGenerateRoute | | Passing data | typed constructor args ✅ | untyped arguments (cast) | | Best for | most apps, type safety | central route registry, simple deep-link names | | Deep linking | manual | easier via onGenerateRoute |

Recommendation for beginners: start with inline push + constructor arguments — it's the most type-safe and obvious. Reach for named routes (or go_router) when you want a central route registry or deep linking.


Useful Navigator variations

Beyond push/pop, a few you'll use often:

  • pushReplacement — replace the current screen instead of stacking (e.g. splash → home; you don't want "back" to return to the splash).
  • pushAndRemoveUntil — push a new screen and clear the stack down to a condition (e.g. after login, go to home and remove the whole auth flow so back doesn't return to it).
  • popUntil — pop repeatedly until a condition (e.g. "back to the first route").
  • maybePop — pop only if it's safe (won't pop the last route).
// After successful login: go home and erase the auth flow from history.
Navigator.pushAndRemoveUntil(
  context,
  MaterialPageRoute(builder: (_) => const HomeScreen()),
  (route) => false, // remove everything below
);

Practice Challenges

Challenge 1 — Open a screen. Write the onPressed that pushes a SecondScreen.

Show solution
onPressed: () => Navigator.push(
  context,
  MaterialPageRoute<void>(builder: (_) => const SecondScreen()),
),

Challenge 2 — Pass data forward. Push a DetailScreen that requires a String title.

Show solution
Navigator.push(
  context,
  MaterialPageRoute<void>(builder: (_) => DetailScreen(title: 'Hello')),
);
// class DetailScreen { final String title; const DetailScreen({required this.title, super.key}); }

Pass data via the destination widget's constructor — fully type-safe.

Challenge 3 — Return a result. Open a PickerScreen, await its returned int, and use it. Include the safety guard.

Show solution
final value = await Navigator.push<int>(
  context,
  MaterialPageRoute<int>(builder: (_) => const PickerScreen()),
);
if (!mounted) return;
if (value != null) setState(() => _n = value);
// In PickerScreen: Navigator.pop(context, 7);

push returns a Future; pop(context, 7) completes it. Handle null (user backed out) and guard mounted after the await.

Challenge 4 — Named routes. Set up a routes table for / and /profile, and navigate to profile.

Show solution
MaterialApp(
  routes: {
    '/': (_) => const HomeScreen(),
    '/profile': (_) => const ProfileScreen(),
  },
)
// Navigate:
Navigator.pushNamed(context, '/profile');

Don't also set home: — the '/' entry is the home screen.

Challenge 5 — Login flow. After login you don't want the user to "back" into the login screen. Which Navigator method, and why?

Show solution

Navigator.pushAndRemoveUntil (with predicate (route) => false) — it pushes the home screen and removes all routes below, so the login/auth flow is erased from the back stack and pressing back won't return to it. (pushReplacement works if you only need to replace the single current screen.)


Questions to test yourself

Q1 (basic). What data structure does Navigator manage, and what do push/pop do to it?

Show answer

A stack of routes (screens). push adds a new route to the top (showing it over the current screen); pop removes the top route, revealing the one beneath. The back button calls pop.

Q2 (basic). What is MaterialPageRoute and what does it provide?

Show answer

It's a route that wraps your destination widget and provides a platform-appropriate transition animation (slide on Android, etc.). Its builder returns the screen widget. You pass it to Navigator.push.

Q3 (intermediate). What's the cleanest, most type-safe way to pass data to a new screen, and why?

Show answer

Pass it through the destination widget's constructor (e.g. DetailScreen(product: p)). Because a route is just a widget, this is ordinary typed Dart — no casting or serialization — so the compiler checks it. (Named-route arguments are untyped Object? you must cast, which is less safe.)

Q4 (intermediate). How do you get a value back from a screen you pushed?

Show answer

Navigator.push returns a Future that completes when the pushed route is popped. On that screen call Navigator.pop(context, result) to complete the future with result. The caller awaits the push to receive it (handling null if the user backed out, and guarding mounted after the await).

Q5 (intermediate). What's the difference between push and pushReplacement? Give a use case for each.

Show answer

push adds a new route on top of the current one (back returns to it). pushReplacement replaces the current route with the new one (back skips it). Use push for normal forward navigation (home → detail); use pushReplacement for splash → home, where you don't want back to return to the splash.

Q6 (advanced). Why does Navigator.push(context, ...) need a context, and when can it fail?

Show answer

context is the widget's Element (Part 2); Navigator.push uses it to walk up the tree to the nearest Navigator (provided by MaterialApp). It fails ("No Navigator/MaterialApp found") if the context is above the MaterialApp (e.g. using the context of the widget that builds MaterialApp itself). Use a context from a widget below MaterialApp — or wrap with a Builder to get one.


Wrapping up

Navigation is "a stack of screens," and Navigator 1.0 is push and pop:

  • Navigator.push(context, MaterialPageRoute(...)) adds a screen; Navigator.pop(context) removes it. The back button pops.
  • Pass data forward via the destination's constructor (type-safe); get data back because push returns a Future that pop(context, result) completes.
  • Named routes (routes table + pushNamed, or onGenerateRoute) centralize route definitions but pass untyped arguments.
  • pushReplacement / pushAndRemoveUntil manage the back stack for flows like splash and login.
  • push needs a context below MaterialApp to find the Navigator.

That's the last core concept. You now understand Flutter from the ground up: what it is and why it's different, the three trees, stateless vs stateful, the build method, flex and stack layout with constraints, hot reload, and navigation. Time to prove it. Part 9 is the 100-question Flutter Fundamentals mastery bank + coding mini-exercises.