The first time your Flutter app needs a second screen, the whole thing feels different. Up to that point you have been building a single page, watching it react to state, and adding widgets like decorations on a tree. Then a button needs to open a details view, or a tap on a list item needs to go somewhere, and suddenly you are facing Flutter navigation for the first time. The good news is that the core idea is simple. The less good news is that the official options have multiplied over the years, so the path from “I want a second screen” to “I have working navigation” feels longer than it should. This post walks you through what is actually happening, the cleanest code to start with, and when to reach for something bigger.
What Navigator actually is
Flutter navigation is built around a thing called the Navigator, and the Navigator is just a stack. You can picture it like a stack of paper screens. The screen on top is the one the user sees. When you push a new screen, you place it on top of the pile, and that becomes the visible one. When you pop, you take the top sheet off, and the screen underneath shows again. That is it. Every fancier routing pattern in Flutter, including named routes and packages like go_router, is built on top of that simple push-and-pop idea.
This stack model lines up nicely with how mobile apps already feel. Tap into a list item, a details screen slides in from the right. Tap back, it slides off again, and you are looking at the list. Your code is doing exactly that: pushing a new route onto the stack, then popping it off later. Once that mental picture is firm, the API stops feeling random and starts feeling almost obvious, and you stop reaching for tutorials every time you need to open a new screen.

One thing worth knowing up front is that the Navigator is created for you. As long as you wrap your app in a MaterialApp (or CupertinoApp), there is a Navigator quietly sitting at the top, ready to receive push and pop calls from anywhere below it. You do not have to set it up by hand. You just have to reach for it from inside your widgets, and the surrounding plumbing takes care of the rest.
Pushing your first new screen
The most direct way to open a new screen is Navigator.push with a MaterialPageRoute. The push call takes a BuildContext (you almost always have one in a widget) and a Route that describes the screen to show. MaterialPageRoute is the everyday choice because it gives you the platform-correct slide and fade transitions for free.
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DetailsScreen(),
),
);
},
child: const Text('Open details'),
),
),
);
}
}
class DetailsScreen extends StatelessWidget {
const DetailsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Details')),
body: const Center(child: Text('You made it.')),
);
}
}
Run that and tap the button. The details screen slides in, and the AppBar gives you a back arrow at no extra charge. The back arrow is calling Navigator.pop for you behind the scenes. That little arrow is the reason a lot of beginners do not realize they are using the navigation stack at all for the first few weeks.
A few details that catch people. The builder you pass to MaterialPageRoute receives its own BuildContext, separate from the one outside it. Use that inner context when you do anything inside the new screen, especially if you want to pop later. Also, MaterialPageRoute does not have to point to a const widget. You can hand it a fresh instance with constructor arguments, and that is how you pass data into the new screen.
Popping back and passing data home
Pop is the mirror image of push. You call Navigator.pop(context), and the top route comes off the stack. The interesting part is that pop can carry a value back to whoever pushed the screen, which is how you collect a result from a picker, a form, or a confirmation dialog without wiring up your own callbacks.
Here is a small example. The home screen asks the user to pick a color on a second screen, and shows the result when they come back.
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
String? chosen;
Future<void> _pickColor() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => const ColorPicker()),
);
if (result != null) {
setState(() => chosen = result);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Chosen: ${chosen ?? 'nothing yet'}'),
ElevatedButton(
onPressed: _pickColor,
child: const Text('Pick a color'),
),
],
),
),
);
}
}
class ColorPicker extends StatelessWidget {
const ColorPicker({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Pick a color')),
body: Center(
child: Wrap(
spacing: 12,
children: ['red', 'green', 'blue'].map((c) {
return ElevatedButton(
onPressed: () => Navigator.pop(context, c),
child: Text(c),
);
}).toList(),
),
),
);
}
}
Two things are worth pointing out. First, Navigator.push returns a Future, and you await it to get the popped value. That is what makes the await pattern feel so natural here: you push, you wait, you get an answer back. Second, the value can be null if the user backs out without choosing, which is why the example checks for null before using it. Beginners often forget the null check, then wonder why their app shows “null” after a back tap. Treat the result as optional and you save yourself the confusion.
There is also a cousin of push that is worth knowing about: Navigator.pushReplacement. It pushes the new screen and pops the old one at the same time, which is exactly what you want after a successful login, where it would feel strange for the user to be able to press back into the login form. The signature is identical to push, so once you know one, you know the other. Reach for pushReplacement whenever moving forward should erase the screen behind you, and stick with regular push everywhere else.

Named routes, and when to graduate to go_router
If you keep pushing MaterialPageRoute by hand all over your app, the code gets repetitive and your screen list ends up scattered across dozens of widgets. Flutter’s older answer to this is named routes: you list your screens once in MaterialApp, then jump to them by name. It looks like this.
MaterialApp(
initialRoute: '/',
routes: {
'/': (_) => const HomeScreen(),
'/details': (_) => const DetailsScreen(),
'/settings': (_) => const SettingsScreen(),
},
)
From anywhere in the app you can then call Navigator.pushNamed(context, ‘/details’) and Flutter looks up the right builder. For small apps with a handful of fixed screens, this is fine, and you will see it in many tutorials. Just know that the official Flutter docs now recommend against relying on named routes for anything beyond the simplest cases, because they get awkward as soon as you need to pass strongly typed arguments, deep link from a URL, or guard a route based on whether the user is signed in.
When your app outgrows that, the package most folks reach for is go_router. It still uses the same push and pop ideas underneath, but it lets you describe the whole map of your app in one place, supports nested navigation, and plays nicely with web URLs out of the box. The real win is not raw power, it is that the routing logic stops living in scattered onPressed handlers and starts living in one place you can read top to bottom. There is no rush. Build a few small apps with plain Navigator.push first so the basics are second nature, and then add go_router when you genuinely feel the pain it solves.
Putting it together
Flutter navigation looks like a thicket of options from the outside, but the path through it is short. There is a stack. You push routes onto it, you pop them off, and you can carry a value back when you pop. Almost everything else, named routes, go_router, deep linking, all of it, is a friendlier way to manage that same stack as your app grows. Start with Navigator.push and MaterialPageRoute, get comfortable with awaiting a popped result, and add the heavier patterns only when you have a real reason. If you are still wiring state across screens, our post on stateless vs stateful widgets in Flutter pairs naturally with this one, and if a navigation call ever silently does nothing the most likely culprit is in our common Flutter mistakes roundup. For the deepest dive, the official Flutter navigation guide is excellent. Get the stack picture clear in your head, and the rest of Flutter navigation falls into place.

