Every Flutter app has more than one screen. This is how you move between them.
Building a single screen in Flutter is straightforward. But the moment your app needs a second screen — a detail page, a settings view, a login form — you need the Navigator. It’s Flutter’s built-in system for moving between screens, passing data forward, returning results backward, and managing the back-button behavior users expect on every device.
This guide covers every navigation method you’ll actually use as a beginner: basic push and pop, sending data to a new screen, returning data back, named routes, and the advanced methods that handle real-world situations like logout flows and multi-step forms. Every section has working code you can copy directly into a project.
Table of Contents
1. The Navigation Stack: The Mental Model
Before writing a single line of navigation code, you need to understand how Flutter thinks about screens. Flutter’s Navigator manages screens as a stack — like a pile of cards. The card on top is what the user sees. Putting a new card on top shows a new screen. Taking the top card off reveals the screen below it.
┌─────────────────────────────────────┐
│ [ Profile Screen ] ← TOP (visible)│
│ [ Detail Screen ] │
│ [ Home Screen ] ← BOTTOM │
└─────────────────────────────────────┘
push() → adds a screen on top 📥
pop() → removes the top screen 📤
| Action | Method | Effect on stack | What the user sees |
|---|---|---|---|
| Go to new screen | push() |
Adds screen to top | New screen with back arrow in AppBar |
| Go back | pop() |
Removes top screen | Previous screen reappears |
| Replace current screen | pushReplacement() |
Swaps top screen | New screen — no back arrow |
| Go home, clear history | pushAndRemoveUntil() |
Clears all, adds new | New screen — can’t go back |
The user always sees the topmost screen. The back button (hardware or AppBar arrow) calls pop() automatically — you don’t wire that up yourself.
2. Method 1: Navigator.push() and pop() — Basic Navigation
This is the foundation everything else builds on. Navigator.push() takes two arguments: the current context and a Route object describing where to go. For regular screen transitions you’ll always use MaterialPageRoute as the route — it wraps your destination widget and applies the correct platform animation automatically.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: HomeScreen()));
// ── SCREEN 1: Home ────────────────────────────────────────────
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home Screen')),
body: Center(
child: ElevatedButton(
child: const Text('Go to Details →'),
onPressed: () {
Navigator.push(
context,
// MaterialPageRoute wraps the destination widget and provides
// the default platform slide animation (Android) / fade (iOS)
MaterialPageRoute(
builder: (context) => const DetailScreen(),
),
);
},
),
),
);
}
}
// ── SCREEN 2: Detail ─────────────────────────────────────────
class DetailScreen extends StatelessWidget {
const DetailScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Detail Screen')),
// Note: the AppBar back arrow already calls pop() automatically.
// The button below is an explicit alternative.
body: Center(
child: ElevatedButton(
child: const Text('← Go Back'),
onPressed: () {
Navigator.pop(context); // removes DetailScreen, shows HomeScreen
},
),
),
);
}
}
| Line | What it does |
|---|---|
Navigator.push(context, route) |
Pushes DetailScreen on top of the stack — user sees it immediately |
MaterialPageRoute(builder: ...) |
Wraps the destination widget; handles the slide/fade animation |
Navigator.pop(context) |
Removes DetailScreen; HomeScreen reappears underneath |
| AppBar back arrow | Calls pop() automatically — you never need to wire this up manually |
3. Method 2: Passing Data Forward to a New Screen
The most common real-world navigation pattern: you tap on an item in a list and need to show its details on the next screen. The mechanism is simple — pass data through the widget’s constructor, the same way you pass any value to any widget.
// ── A simple data model ───────────────────────────────────────
class Product {
final String name;
final double price;
final String description;
const Product({
required this.name,
required this.price,
required this.description,
});
}
// ── SCREEN 1: Product list ────────────────────────────────────
class ProductListScreen extends StatelessWidget {
const ProductListScreen({super.key});
// Sample data — in a real app this would come from an API or database
final List<Product> products = const [
Product(name: 'Wireless Headphones', price: 59.99, description: 'Over-ear, 30hr battery'),
Product(name: 'Mechanical Keyboard', price: 89.99, description: 'Cherry MX switches'),
Product(name: 'USB-C Hub', price: 34.99, description: '7-in-1 multiport adapter'),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
subtitle: Text('$${product.price.toStringAsFixed(2)}'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
// Pass the whole Product object through the constructor
builder: (context) => ProductDetailScreen(product: product),
),
);
},
);
},
),
);
}
}
// ── SCREEN 2: Product detail ──────────────────────────────────
class ProductDetailScreen extends StatelessWidget {
// Receive the data via constructor — same as any other widget property
final Product product;
const ProductDetailScreen({super.key, required this.product});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(product.name)),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(fontSize: 26, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'$${product.price.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 20, color: Colors.green),
),
const SizedBox(height: 16),
Text(product.description, style: const TextStyle(fontSize: 16)),
],
),
),
);
}
}
4. Method 3: Returning Data Back with pop()
Sometimes Screen 2 isn’t just a destination — it’s a picker. The user makes a selection (a color, a date, a category) and you need that value back on Screen 1. Flutter handles this elegantly: Navigator.push() returns a Future, and you await it. When Screen 2 calls Navigator.pop(context, value), that value is what the Future resolves with.
// ── SCREEN 1: Waiting for a result ───────────────────────────
class ShirtConfigScreen extends StatefulWidget {
const ShirtConfigScreen({super.key});
@override
State<ShirtConfigScreen> createState() => _ShirtConfigScreenState();
}
class _ShirtConfigScreenState extends State<ShirtConfigScreen> {
String _selectedColor = 'Not chosen yet';
void _openColorPicker() async {
// push() returns a Future — await it to get the value Screen 2 sends back
final String? result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (context) => const ColorPickerScreen()),
);
// result is null if user pressed back without selecting
if (result != null) {
setState(() {
_selectedColor = result;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Configure Shirt')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Selected color: $_selectedColor',
style: const TextStyle(fontSize: 18)),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _openColorPicker,
child: const Text('Choose a Color'),
),
],
),
),
);
}
}
// ── SCREEN 2: Returns a value on pop() ───────────────────────
class ColorPickerScreen extends StatelessWidget {
const ColorPickerScreen({super.key});
// Reusable tile for each color option
Widget _colorTile(BuildContext context, String color, Color swatch) {
return ListTile(
leading: CircleAvatar(backgroundColor: swatch),
title: Text(color),
onTap: () {
// Pass the chosen color string back to Screen 1
Navigator.pop(context, color);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Pick a Color')),
body: Column(
children: [
_colorTile(context, 'Red', Colors.red),
_colorTile(context, 'Blue', Colors.blue),
_colorTile(context, 'Green', Colors.green),
_colorTile(context, 'Black', Colors.black87),
],
),
);
}
}
| Code | What it does |
|---|---|
await Navigator.push<String>(...) |
Navigates and waits — execution pauses here until Screen 2 pops |
Navigator.pop(context, color) |
Closes Screen 2 and sends color as the return value |
if (result != null) |
Guards against the user pressing back without selecting anything |
setState(() { ... }) |
Updates Screen 1’s UI to show the returned value |
5. Method 4: Named Routes
As your app grows past 3–4 screens, navigation code scattered across files becomes hard to maintain. Named routes solve this by letting you register all your screens in one central place inside MaterialApp, then navigate using simple string paths — almost like URLs in a website.
Step 1 — Register all routes in MaterialApp
void main() {
runApp(MaterialApp(
title: 'My App',
// initialRoute is the first screen — replaces 'home:'
initialRoute: '/',
routes: {
'/': (context) => const HomeScreen(),
'/products': (context) => const ProductListScreen(),
'/profile': (context) => const ProfileScreen(),
'/settings': (context) => const SettingsScreen(),
},
));
}
Step 2 — Navigate with pushNamed()
// Go to any registered screen by its route name
Navigator.pushNamed(context, '/products');
Navigator.pushNamed(context, '/profile');
Navigator.pushNamed(context, '/settings');
// Go back — exactly the same as with push()
Navigator.pop(context);
Full Two-Screen Named Routes Example
import 'package:flutter/material.dart';
void main() {
runApp(MaterialApp(
title: 'Named Routes Demo',
initialRoute: '/',
routes: {
'/': (context) => const HomeScreen(),
'/details': (context) => const DetailScreen(),
},
));
}
// ── SCREEN 1 ──────────────────────────────────────────────────
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.pushNamed(context, '/details'),
child: const Text('Go to Details →'),
),
),
);
}
}
// ── SCREEN 2 ──────────────────────────────────────────────────
class DetailScreen extends StatelessWidget {
const DetailScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Details')),
body: Center(
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('← Back to Home'),
),
),
);
}
}
6. Method 5: Passing Data with Named Routes
Named routes can’t use constructors for data — instead, Flutter provides an arguments parameter. You pass data on the way in and retrieve it on the destination screen using ModalRoute.of(context).
// ── SCREEN 1: Send arguments ──────────────────────────────────
Navigator.pushNamed(
context,
'/profile',
arguments: {
'username': 'Priya',
'memberSince': '2023',
},
);
// ── SCREEN 2: Receive arguments ───────────────────────────────
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
// Cast to the correct type — here a Map with String keys
final args = ModalRoute.of(context)!.settings.arguments
as Map<String, String>;
final username = args['username'] ?? 'Unknown';
final memberSince = args['memberSince'] ?? 'Unknown';
return Scaffold(
appBar: AppBar(title: Text('Profile: $username')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Welcome back, $username! 👋',
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Text('Member since: $memberSince',
style: const TextStyle(fontSize: 16, color: Colors.grey)),
],
),
),
);
}
}
arguments approach works well but requires casting, which bypasses compile-time type safety. For complex data or large apps, most Flutter developers prefer push() with typed constructors, or upgrade to the GoRouter package which gives you URL-like routing with full type safety.
7. Method 6: Advanced Navigation Methods
Once you have push/pop and named routes down, these four methods cover the remaining real-world scenarios you’ll encounter in any real app:
| Method | What it does | Classic use case |
|---|---|---|
pushReplacement() |
Replaces current screen — no back button | Splash screen → Home, Login success → Dashboard |
pushAndRemoveUntil() |
Clears entire history, pushes new screen | Logout → Login (user can’t press back into app) |
popUntil() |
Pops multiple screens at once until condition met | Cancel a multi-step wizard, go back to root |
pushNamedAndRemoveUntil() |
Named route + clears all history below | Login → Home (named routes version) |
pushReplacement() — Splash to Home
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
@override
void initState() {
super.initState();
// After 2 seconds, replace splash with home — no back button to splash
Future.delayed(const Duration(seconds: 2), () {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const HomeScreen()),
);
});
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: FlutterLogo(size: 120),
),
);
}
}
pushAndRemoveUntil() — Logout
void _logout(BuildContext context) {
// Clear all authentication state here...
// Then wipe the entire navigation stack and go to login
// (route) => false means: remove ALL previous routes
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
(route) => false,
);
}
// Named route version — same effect:
Navigator.pushNamedAndRemoveUntil(
context,
'/login',
(route) => false,
);
popUntil() — Cancel a multi-step form
// User is on step 3 of 3 and taps "Cancel" — jump all the way back to home
void _cancelForm(BuildContext context) {
Navigator.popUntil(context, ModalRoute.withName('/'));
// ModalRoute.withName('/') = keep popping until the '/' route is on top
}
8. push() vs Named Routes: When to Use Which
| Scenario | Best choice | Why |
|---|---|---|
| Small app (2–4 screens) | push() |
No setup needed, simpler code |
| Medium / large app | Named routes | All routes in one place — easier to audit and refactor |
| Passing a typed object to the next screen | push() with constructor |
Type-safe, no casting needed |
| Deep links or web URL support | Named routes or GoRouter | URLs map directly to route strings |
| Production app with complex routing | GoRouter package | Type-safe parameters, deep linking, nested navigation |
push() / pop() and named routes first — they’re what every Flutter tutorial, course, and job will expect you to know. Once you’re comfortable, GoRouter is the natural next step for production-grade apps.
9. Common Beginner Mistakes
Mistake 1: Calling push without context
// ❌ WRONG — context is always required
Navigator.push(DetailScreen());
// ✅ CORRECT — context tells Flutter which Navigator widget to use
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const DetailScreen()),
);
Mistake 2: Case mismatch in named route string
// Route registered as:
routes: { '/details': (context) => const DetailScreen() }
// ❌ WRONG — route strings are case-sensitive
Navigator.pushNamed(context, '/Details'); // capital D → not found, crash
// ✅ CORRECT — matches exactly
Navigator.pushNamed(context, '/details');
Mistake 3: Using both home: and initialRoute: together
// ❌ WRONG — these two conflict, Flutter will throw an assertion error
MaterialApp(
home: HomeScreen(), // can't use both
initialRoute: '/', // can't use both
)
// ✅ CORRECT — when using named routes, use initialRoute only
MaterialApp(
initialRoute: '/',
routes: { '/': (context) => const HomeScreen() },
)
Mistake 4: Not checking for null after awaiting push()
// ❌ WRONG — result is null if user pressed the back button, not the save button
final result = await Navigator.push(...);
setState(() { _value = result; }); // crashes if result is null
// ✅ CORRECT — always null-check the result
final result = await Navigator.push(...);
if (result != null) {
setState(() { _value = result; });
}
10. Beginner Interview Q&A
These are the navigation questions that come up most consistently in entry-level Flutter interviews.
Q1: What is the Navigator in Flutter?
push() to add screens and pop() to remove them. The user always sees the topmost route on the stack. Every Flutter app has a root Navigator provided automatically by MaterialApp.
Q2: What is the difference between push() and pushReplacement()?
push() adds a new screen on top of the stack — the current screen stays below it and the user can press back to return. pushReplacement() swaps out the current screen entirely, so the back button will not bring the user back to it. This is the correct choice after a splash screen or a successful login.
Q3: What is MaterialPageRoute?
Navigator.push().
Q4: When would you use pushAndRemoveUntil()?
Q5: What is the difference between push() and pushNamed()?
push() navigates by directly constructing the destination widget inline — flexible and type-safe for passing data. pushNamed() navigates using a string key that maps to a screen registered in MaterialApp‘s routes table — cleaner for larger apps where you want all routes defined centrally.
Q6: How do you send data between screens?
push(), pass data through the destination widget’s constructor — this is type-safe and the recommended approach for complex objects. With pushNamed(), pass data via the arguments parameter and retrieve it on the destination screen using ModalRoute.of(context)!.settings.arguments, with a cast to the expected type.
See Navigation in a Real App
The best way to cement all of this is to see it working in a real codebase. Our notes app series uses push(), pop(), and data passing throughout — and the code is fully explained line by line:
| Article | Navigation concepts used |
|---|---|
| Notes App Part 1: Project Setup & Core UI | Navigator.push(), pop(), returning data from an editor screen |
| Notes App Part 2: Local Persistence & Polish | Navigation combined with async state updates and persistent storage |

Pingback: Flutter Tic-Tac-Toe Part 3: Single-Player AI with Minimax Algorithm
Pingback: Flutter GoRouter Tutorial: Stop Fighting Navigation and Start Using URLs