Flutter Navigation: How to Move Between Screens

Learn Flutter navigation step by step: Navigator.push and pop, passing data to a screen, returning results, pushReplacement for login flows, named routes, and when to use go_router.

By Yaqoob Developer · · 7 min read

  • #Flutter
  • #Navigation
  • #Navigator
  • #Routes
  • #Beginners

Real apps have more than one screen: a product list and a product details page, a settings page, a profile page. In this guide you will learn how to move between screens in Flutter, pass data to a new screen, get a result back, and use named routes.

All examples use Flutter's built-in Navigator, so you do not need any packages.

In this post you will learn:

  • How Flutter's navigation stack works
  • How to open a new screen with Navigator.push
  • How to go back with Navigator.pop
  • How to send data to the next screen
  • How to get data back from a screen
  • How to replace screens (for example after login)
  • How to use named routes
  • When to use a package like go_router

How navigation works: the stack

In Flutter, every screen is called a route. The Navigator keeps routes in a stack, like a pile of plates:

  • push puts a new screen on top of the pile. That is the screen you see.
  • pop removes the top screen, and the one underneath becomes visible again.

Navigator stack: push adds a screen on top, pop removes it

The phone's back button and the back arrow in the AppBar both call pop for you automatically.

Step 1: Create two screens

Start with a home screen and a second screen:

import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(
    debugShowCheckedModeBanner: false,
    home: HomeScreen(),
  ));
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(
        child: FilledButton(
          onPressed: () {
            // We will add navigation here
          },
          child: const Text('Open second screen'),
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Second screen')),
      body: const Center(child: Text('Hello from the second screen!')),
    );
  }
}

Step 2: Open a screen with Navigator.push

Replace the comment in onPressed with:

onPressed: () {
  Navigator.push(
    context,
    MaterialPageRoute(builder: (context) => const SecondScreen()),
  );
},
  • Navigator.push adds a new route to the stack.
  • MaterialPageRoute creates a route with the standard animation for each platform: a slide on iOS and a fade-and-zoom on Android.
  • builder returns the screen widget to show.

Run the app and tap the button. The second screen slides in, and the AppBar automatically shows a back arrow.

Step 3: Go back with Navigator.pop

The back arrow already works, but you can also go back from your own button:

FilledButton(
  onPressed: () => Navigator.pop(context),
  child: const Text('Go back'),
)

Navigator.pop(context) removes the current screen from the stack.

Step 4: Send data to the next screen

The cleanest way to pass data is through the constructor of the new screen. Here is a list of products where tapping one opens its details:

class Product {
  const Product({required this.name, required this.price, required this.icon});

  final String name;
  final double price;
  final IconData icon;
}

const products = [
  Product(name: 'Headphones', price: 59.99, icon: Icons.headphones),
  Product(name: 'Smart Watch', price: 129.00, icon: Icons.watch),
  Product(name: 'Camera', price: 349.50, icon: Icons.photo_camera),
  Product(name: 'Speaker', price: 79.00, icon: Icons.speaker),
];

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

  @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(
            leading: Icon(product.icon),
            title: Text(product.name),
            subtitle: Text('\$${product.price.toStringAsFixed(2)}'),
            trailing: const Icon(Icons.chevron_right),
            onTap: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => ProductDetailScreen(product: product),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

class ProductDetailScreen extends StatelessWidget {
  const ProductDetailScreen({super.key, required this.product});

  final Product product;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(product.name)),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Icon(product.icon, size: 120),
            const SizedBox(height: 24),
            Text(
              product.name,
              style: Theme.of(context).textTheme.headlineMedium,
            ),
            const SizedBox(height: 8),
            Text(
              '\$${product.price.toStringAsFixed(2)}',
              style: Theme.of(context).textTheme.titleLarge,
            ),
          ],
        ),
      ),
    );
  }
}

Tapping a product in the list opens its detail screen with the data passed through the constructor

ProductDetailScreen asks for a required this.product in its constructor, so it is impossible to open it without passing a product. The compiler protects you from forgetting.

Step 5: Get data back from a screen

Sometimes the second screen needs to return something: a selected color, an edited name, or "yes / no" from a confirmation page.

  1. Navigator.push returns a Future. Await it to get the result.
  2. On the second screen, pass the result as the second argument to Navigator.pop.
class ColorPickerScreen extends StatelessWidget {
  const ColorPickerScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final colors = {
      'Red': Colors.red,
      'Green': Colors.green,
      'Blue': Colors.blue,
    };

    return Scaffold(
      appBar: AppBar(title: const Text('Pick a color')),
      body: ListView(
        children: [
          for (final entry in colors.entries)
            ListTile(
              leading: CircleAvatar(backgroundColor: entry.value),
              title: Text(entry.key),
              // Send the chosen name back to the previous screen
              onTap: () => Navigator.pop(context, entry.key),
            ),
        ],
      ),
    );
  }
}

And on the first screen:

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

  @override
  State<ProfileScreen> createState() => _ProfileScreenState();
}

class _ProfileScreenState extends State<ProfileScreen> {
  String _favoriteColor = 'None';

  Future<void> _pickColor() async {
    final result = await Navigator.push<String>(
      context,
      MaterialPageRoute(builder: (context) => const ColorPickerScreen()),
    );

    // result is null if the user pressed back without choosing
    if (result != null) {
      setState(() => _favoriteColor = result);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Profile')),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text('Favorite color: $_favoriteColor'),
            const SizedBox(height: 16),
            FilledButton(onPressed: _pickColor, child: const Text('Choose color')),
          ],
        ),
      ),
    );
  }
}

Important details:

  • Navigator.push<String> tells Dart the result will be a String.
  • The result is null if the user leaves with the back button instead of picking something. Always check for null.

Step 6: Replace screens

Sometimes the user should not be able to go back. After logging in, pressing back should not return to the login screen.

pushReplacement

Replaces the current screen with a new one:

Navigator.pushReplacement(
  context,
  MaterialPageRoute(builder: (context) => const HomeScreen()),
);

Use it for: login → home, splash screen → home, onboarding → home.

pushAndRemoveUntil

Opens a new screen and removes every screen below it:

Navigator.pushAndRemoveUntil(
  context,
  MaterialPageRoute(builder: (context) => const LoginScreen()),
  (route) => false, // false = remove all previous routes
);

Use it for logout: the user lands on the login screen with an empty history.

popUntil

Goes back several screens at once, for example to the very first screen:

Navigator.popUntil(context, (route) => route.isFirst);

Step 7: Named routes

In bigger apps, you can give each screen a name and register all of them in one place:

void main() {
  runApp(MaterialApp(
    initialRoute: '/',
    routes: {
      '/': (context) => const HomeScreen(),
      '/settings': (context) => const SettingsScreen(),
      '/about': (context) => const AboutScreen(),
    },
  ));
}

Then navigate with just the name:

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

To pass data with a named route, use arguments:

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

And read it on the new screen:

final product = ModalRoute.of(context)!.settings.arguments as Product;

When you use routes, do not also set home. The '/' route is the home screen.

Named routes keep all screen names in one place, but arguments are not type-checked. If you pass the wrong type, the app crashes at runtime. For small and medium apps, passing data through constructors (Step 4) is safer and simpler.

Which method should I use?

SituationUse
Open a screenNavigator.push
Go backNavigator.pop(context)
Send data forwardconstructor parameter
Get data backawait Navigator.push + Navigator.pop(context, result)
Login → home, splash → homeNavigator.pushReplacement
LogoutNavigator.pushAndRemoveUntil(..., (route) => false)
Back to the first screenNavigator.popUntil(context, (route) => route.isFirst)
Many screens, one central listnamed routes
Web URLs, deep links, large appsthe go_router package

What about go_router?

go_router is an official Flutter package for URL-based navigation. It is worth learning when your app runs on the web (so each screen has a real URL), needs deep links (opening a specific screen from a link or notification), or has many nested screens. For learning and for most beginner apps, Navigator is all you need.

Common mistakes

You used the context of the widget that creates MaterialApp. That context sits above the Navigator. Move your screen into its own widget (like HomeScreen above) and use the context inside its build method.

Using context after await

After await Navigator.push(...), the screen might have been closed. Check if (!mounted) return; (in a State class) or if (!context.mounted) return; before using context again.

The back button returns to the login screen

You used push after login. Use pushReplacement or pushAndRemoveUntil instead.

"type 'Null' is not a subtype of type 'Product'"

You opened a named route without passing arguments, but the screen expects them. Always pass arguments, or use constructor parameters instead of named routes.

What to learn next

Keep reading

More articles