Navigator.push, pushReplacement, and go_router: Flutter Navigation

- Andrés Cruz - ES En español

Navigation is one of the topics that causes the most headaches when starting with Flutter… and, curiously, also one of the easiest to master once you get the hang of it. In my first projects, I realized that almost everything relies on the same concept: managing a route stack as if it were a real stack (push, pop… and a bit of magic).

In this guide, I'll take you from scratch to advanced concepts: basic Navigator, Navigator 2.0, go_router, nested routes, practical cases, and real-world best practices that have saved my projects more than once.

We can compare navigation in Flutter to how the stack data structure works: each screen you open is stacked on top of the previous one, and to go back you simply pop elements from the top.

We continue from the previous post, where we learned how to use the FutureBuilder, async, and await in Flutter.

What is navigation in Flutter and how the route stack works

How Flutter manages screens as routes

In Flutter, every screen is a route that is stacked on top of another. When you navigate forward, you push a new route onto the stack; when you go back, you pop it off. The widget responsible for managing all of this is the Navigator class.

When I understood it like this —as a simple stack— everything made sense: "If I push 5 screens, I'll need 5 pops to go back." In my early apps (product lists, detail screens, etc.), this mental analogy helped me avoid bugs that would otherwise have been very difficult to debug.

Differences between mobile navigation and Flutter Web

It is important to keep in mind that navigation does not behave the same way on all platforms. In mobile apps:

  • Screen transition animations take priority.
  • Visual transitions are part of the user experience.
  • History is internal to the app and the user controls it using the device's "Back" button.

In Flutter Web, the scenario changes considerably:

  • Routes become actual browser URLs.
  • The browser's "Back" button must be properly integrated with your route stack.
  • You need your navigation to be declarative so that the URL always reflects the current state of the app.

Navigator in Flutter: essential concepts and first steps

Keep in mind that screens in Flutter are officially called routes, but for simplicity, I will use the terms "screen" and "page" interchangeably. Flutter centralizes all navigation management in the Navigator class. In this article, we will cover its five main methods:

  1. Navigator.push()
  2. Navigator.pop()
  3. Navigator.pushReplacement()
  4. Navigator.pushAndRemoveUntil()
  5. Navigator.popUntil()

Imagine you are building an online shopping app: you want to show item details on a new screen when the user selects it from a list, and return to the list when they tap "Back". For this basic use case, these methods are enough:

  1. Navigator.push()
  2. Navigator.pop()

The Navigator class provides all navigation capabilities within a Flutter application.

Navigator mutates the route stack by adding and removing pages through the mentioned methods. Navigator.push() navigates to a new screen, and Navigator.pop() returns from the current screen to the previous one.

The push() method receives a BuildContext as the first argument and a PageRoute as the second. The most common way to build that route is with MaterialPageRoute.

Navigator.push() and Navigator.pop() explained practically

  • Navigator.push() adds a new screen to the route stack.
    Navigator.pop() removes the current screen and returns you to the previous one.

When I built my first app with detail screens, I experienced it exactly like this: "I tap an item → push; I tap Back → pop". Simple, direct, and effective.

Navigator.push(
  context,
  MaterialPageRoute(builder: (_) => DetalleScreen()),
);

To return to the previous screen, simply call Navigator.pop(context). If you also need to return a result to the previous screen, you can pass it as a second argument: Navigator.pop(context, result). The screen that performed the push can capture that value using await.

pushReplacement, pushAndRemoveUntil, and popUntil in real workflows

The three advanced Navigator methods solve scenarios that push and pop cannot handle alone:

  • Navigator.pushReplacement(): ideal for the login → home flow.
    It replaces the current route with the new one, so when pressing "Back", the user cannot return to the replaced screen. In one of my projects, I used it to prevent the user from returning to the login screen after authenticating… and it worked like magic.
  • Navigator.pushAndRemoveUntil(): perfect for logging out or resetting an entire flow.
    It pushes a new route and removes all previous routes from the stack until a condition (the predicate) is met. In a checkout app, when the payment was completed, I used it to clear all history before taking the user to the order summary.
  • Navigator.popUntil(): useful when you want to return to a specific screen without executing pop one by one.
    I used it to jump from the payment confirmation screen directly back to the beginning of a flow, without the user seeing all intermediate screens when tapping "Back".

Passing data between screens: arguments, ModalRoute, and results

You can pass data to a new route using Navigator.pushNamed() with the arguments parameter:

Navigator.pushNamed(
  context,
  '/detalle',
  arguments: {'id': 25, 'nombre': 'Pepe'},
);

And you retrieve it on the destination screen by accessing ModalRoute:

final args = ModalRoute.of(context)!.settings.arguments;

It is important to validate the type before using the arguments, as settings.arguments returns Object?. An explicit cast like args as Map<String, dynamic> is the recommended practice.

Named routes: when they are suitable and how to organize them

Named routes allow you to navigate using text strings instead of directly instantiating classes, which promotes code reusability and maintainability. They are especially useful when you have:

  • Repetitive navigation from multiple points in the app.
  • A need for greater long-term maintainability.
  • Well-structured and centralized routes.
  • Parameters, filters, or detail screens invoked from multiple places.

Defining named routes

Routes are defined as a Map<String, WidgetBuilder> and passed to the routes property of MaterialApp:

void main() {
  runApp(MaterialApp(
    title: 'My App',
    home: Main(),
    routes: {
      '/page': (context) => PageRoute(),
    },
  ));
}

Using named routes

Instead of Navigator.push(), you use Navigator.pushNamed() to navigate to a route by its name. Similarly, Navigator.pushReplacementNamed() replaces the current route without leaving it on the stack. The Navigator.pop() method works the same for all route types.

class Main extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Main Route'),
      ),
      body: Center(
        child: ElevatedButton(
          child: Text('Open route'),
          onPressed: () {
            Navigator.pushReplacementNamed(context, '/page');
          },
        ),
      ),
    );
  }
}

Note: RaisedButton was deprecated in recent versions of Flutter. Use ElevatedButton instead.

Navigator 2.0: declarative navigation and history management

What Navigator 2.0 solves and why it is key in Flutter Web

Navigator 2.0 (also called the declarative Router API) was created to solve scenarios that Navigator 1.0 could not handle elegantly:

  • Complex deep linking with nested routes.
  • Two-way synchronization between the UI state and the browser URL.
  • Non-linear navigation flows (e.g., wizards with optional steps).
  • Greater programmatic control over navigation history.

In a web app I built, using RouterDelegate allowed the browser URL to always reflect the actual state of navigation, which is essential for SEO and user experience on the web.

RouterDelegate and RouteInformationParser in simple words

Navigator 2.0 relies on two key pieces:

  • RouteInformationParser: interprets the incoming URL and converts it into a state object that your app can understand.
  • RouterDelegate: receives that state and decides which screens to render on the stack. It has total control over the route tree.

Practical case: URL-based navigation and deep linking

Deep linking allows someone to open your app directly at a specific route, whether from an external link, a push notification, or an email. For example:

  • /producto/123
  • /perfil/editar
  • /tarea/7

Navigator 2.0 supports it natively

That said, configuring Navigator 2.0 manually can quickly become verbose and complex. That is why in most projects it is recommended to use go_router, which abstracts all that complexity while keeping the power of Navigator 2.0 under the hood.

go_router in Flutter: modern, declarative navigation

Why go_router simplifies navigation in large apps

Here comes the part that changed my life as a Flutter developer: go_router.

In my large projects, organized by features, I noticed that Navigator 1.0 became unmanageable as the number of screens grew. go_router gave me exactly what I needed:

  • Declarative routes defined in a single place.
  • Clean and predictable URLs.
  • Clearer path parameters and query params.
  • Easy-to-implement nested routes.
  • Powerful conditional redirects.

Declarative route definition and dynamic parameters

With go_router, you define each route using GoRoute and access URL parameters directly from state.pathParameters:

GoRoute(
  path: 'details/:id',
  builder: (_, state) => DetalleScreen(id: state.pathParameters['id']!),
);

Using context.go(), context.goNamed(), and extracting parameters

To navigate with go_router, you have two main options:

  • context.go('/detalles/8'): navigates directly using the path.
  • context.goNamed('detalle', pathParameters: {'id': '8'}): navigates using the route name (safer and more scalable).

Redirects, route protection, and state control

One of the most powerful features of go_router is the ability to protect routes using the redirect parameter. If the user is not authenticated, you can automatically send them to login:

redirect: (_, state) {
  if (!estaLogueado) return '/login';
  return null;
}

Returning null means there is no redirection and the route loads normally. This logic can be as complex as you need, querying the state of your favorite state manager (Provider, Riverpod, Bloc, etc.).

Custom transitions with go_router

You can animate each route individually using CustomTransitionPage alongside transitionsBuilder. This gives you full control over the transition animation (fade, slide, scale) without leaving the declarative API of go_router.

Nested routes and project organization by features

How to structure modules and child routes with go_router

A very common use case is a task list with its child screens: task details and a screen to add a new task. With go_router, you can declare child routes cleanly:

GoRoute(
  path: '/',
  builder: (_, __) => ListaTareasScreen(),
  routes: [
    GoRoute(
      path: 'details/:taskId',
      builder: (_, state) => DetalleTareaScreen(
        taskId: state.pathParameters['taskId']!,
      ),
    ),
    GoRoute(
      path: 'nueva',
      builder: (_, __) => NuevaTareaScreen(),
    ),
  ],
);

Practical case: task list with details and creation screen

When I implemented a task app with this structure, I discovered something that changed how I organize projects:

  • The task list is the parent route (/).
  • The task detail is a child route (/details/:taskId).
  • Adding a new task is another child route (/nueva).
  • Everything remains readable, modular, and easy to maintain.

The benefits of nested navigation in scalable apps are clear:

  • Easier maintenance: each feature manages its own routes.
  • Predictable and meaningful URLs.
  • Separation by features, making teamwork easier.
  • Full control over navigation flow without spaghetti code.

Complete comparison: Navigator vs Navigator 2.0 vs go_router

What to choose according to your application's size and complexity

There is no single answer. The choice depends on your project context:

ScenarioBest option
Small app or prototypeNavigator 1.0
Medium app with simple statesNamed routes (pushNamed)
Large or modular appgo_router
Flutter Web app with URLsNavigator 2.0 or go_router
Advanced deep linkinggo_router or Navigator 2.0

Table of real-world use cases and recommended solutions

Real caseRecommended solution
Avoid returning to login after authenticationNavigator.pushReplacement() or redirect in go_router
URL navigation in Flutter Webgo_router / Navigator 2.0
Checkout-like flows (clearing stack)Navigator.pushAndRemoveUntil()
Child routes with parametersgo_router
Web-style SPA with declarative navigationgo_router

Best practices to avoid common navigation mistakes

  • Do not mix Navigator 1.0 and go_router unnecessarily: pick one and stay consistent.
  • Keep route definitions out of UI widgets, in dedicated files or classes.
  • Use route names instead of hardcoded paths: if you change a URL, you only update it in one place.
  • Avoid coupling business state to routes: navigation should be a consequence of state, not its cause.

Best practices and patterns for robust navigation

Organizing routes by modules or features

In medium or large apps, I always organize routes by feature. A typical structure I use is:

/features/tareas
  /presentation    ← widgets and screens
  /routes          ← GoRoute definition for this feature
  /data            ← repositories and models

This makes each feature autonomous, ensuring that adding or removing a module does not affect the rest.

State management in navigation flows

The state manager you choose directly influences how you protect routes and pass data. My recommendation based on project size:

  • Small project → setState is enough.
  • Medium project → Provider for simple global state.
  • Large project → Riverpod or Bloc for granular and testable control.

Code cleanups and recommended architecture

  • Separate navigation logic from UI: your widgets shouldn't need to know how the router is defined.
  • Use typed models to pass complex parameters between routes, instead of generic Map<String, dynamic>.
  • Avoid placing business logic inside route builders; delegate that responsibility to widgets or the presentation layer.

Why you shouldn't use push() and should use pushReplacement() when navigating between courses

Video thumbnail

Content Index

I wanted to share a real anecdote that happened to me while building the DesarrolloLibre application, the very same one you are looking at on screen right now.

The issue arose with the DrawerComponent, which allows navigating between various sections of the app. Specifically, the drawback appeared in the listing of all courses: when entering the details of one course and then wanting to jump to another course's details, I had to open the DrawerComponent and tap "All Courses" again to reset the list.

The problem with Navigator.push()

Previously, I was simply using:

Navigator.push(context, MaterialPageRoute(builder: (_) => DetalleCursoScreen()));

The issue with Navigator.push() is that it queues each of the previous views onto the stack. Therefore, if the user navigated through several courses, pressing "Back" returned them to the previous course instead of the main listing. If they checked 10 courses, 10 routes accumulated on the stack.

This created an even more serious issue: when dispose() was executed on the current course widget, the page state mixed with that of previous pages still residing on the stack. This caused the application to crash when trying to access out-of-range elements in one of the lesson lists.

Solution with Navigator.pushReplacement()

The key is identifying what your app really needs to work correctly.

In my case, it made no sense to keep all previous course views queued, as they only consumed unnecessary memory and rendering time.

The solution was to replace every navigation between courses with:

Navigator.pushReplacement(
  context,
  MaterialPageRoute(builder: (_) => DetalleCursoScreen()),
);

With Navigator.pushReplacement(), every time the user navigates to a new course, the previous screen is replaced rather than stacked. The stack doesn't grow, dispose() executes properly, and state issues disappear completely.

Frequently Asked Questions about Navigation in Flutter

  • Difference between context.go() and context.goNamed()?
    • context.go('/ruta'): navigates directly using the path string.
    • context.goNamed('nombre'): navigates using the route name. It is safer and more scalable because the path can change without breaking navigation.
  • How to protect routes with redirects?
    • Using the redirect parameter in go_router, or via custom guards in RouterDelegate if you are using Navigator 2.0 directly.
  • How to pass additional data to a route without exposing it in the URL?
    • Use the extra parameter in go_router:
    • context.goNamed('detalle', extra: {'modo': 'edicion'});
    • Keep in mind that extra does not survive a page reload in Flutter Web, so for data that must persist in the URL, use pathParameters or queryParameters.
  • How to control history in Flutter Web?
    • Use declarative navigation (go_router or Navigator 2.0) and avoid manipulating the stack manually with push/pop. Browser history will automatically sync with your route stack.
  • When to use Navigator.pushAndRemoveUntil()?
    • When you need to clear the entire navigation history, such as when logging out or completing an onboarding flow. You can clear the whole stack using (route) => false as the predicate, or up to a specific route with ModalRoute.withName('/home').

Conclusion: how to choose the best navigation strategy for your Flutter app

Navigation is more than just moving from one screen to another: it is the backbone of your app's user flow. With Navigator 1.0, you can move fast in small projects; with Navigator 2.0, you gain total control over history; and with go_router, you get a clean, scalable, and declarative navigation setup.

Next step, discover Flutter's Scaffold Widget.

Master navigation in Flutter: Navigator.push(), pushReplacement(), pushAndRemoveUntil(), popUntil(), and go_router. A practical guide featuring real-world examples, comparison tables, and best practices.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.