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:
Navigator.push()Navigator.pop()Navigator.pushReplacement()Navigator.pushAndRemoveUntil()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:
Navigator.push()Navigator.pop()
The Navigator class provides all navigation capabilities within a Flutter application.
Navigatormutates the route stack by adding and removing pages through the mentioned methods.Navigator.push()navigates to a new screen, andNavigator.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 executingpopone 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:
| Scenario | Best option |
|---|---|
| Small app or prototype | Navigator 1.0 |
| Medium app with simple states | Named routes (pushNamed) |
| Large or modular app | go_router |
| Flutter Web app with URLs | Navigator 2.0 or go_router |
| Advanced deep linking | go_router or Navigator 2.0 |
Table of real-world use cases and recommended solutions
| Real case | Recommended solution |
|---|---|
| Avoid returning to login after authentication | Navigator.pushReplacement() or redirect in go_router |
| URL navigation in Flutter Web | go_router / Navigator 2.0 |
| Checkout-like flows (clearing stack) | Navigator.pushAndRemoveUntil() |
| Child routes with parameters | go_router |
| Web-style SPA with declarative navigation | go_router |
Best practices to avoid common navigation mistakes
- Do not mix
Navigator1.0 andgo_routerunnecessarily: 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 modelsThis 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 →
setStateis enough. - Medium project →
Providerfor simple global state. - Large project →
RiverpodorBlocfor 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
Content Index
- What is navigation in Flutter and how the route stack works
- How Flutter manages screens as routes
- Differences between mobile navigation and Flutter Web
- Navigator in Flutter: essential concepts and first steps
- Navigator.push() and Navigator.pop() explained practically
- pushReplacement, pushAndRemoveUntil, and popUntil in real workflows
- Passing data between screens: arguments, ModalRoute, and results
- Named routes: when they are suitable and how to organize them
- Defining named routes
- Using named routes
- Navigator 2.0: declarative navigation and history management
- What Navigator 2.0 solves and why it is key in Flutter Web
- RouterDelegate and RouteInformationParser in simple words
- Practical case: URL-based navigation and deep linking
- Navigator 2.0 supports it natively
- go_router in Flutter: modern, declarative navigation
- Why go_router simplifies navigation in large apps
- Declarative route definition and dynamic parameters
- Using context.go(), context.goNamed(), and extracting parameters
- Redirects, route protection, and state control
- Custom transitions with go_router
- Nested routes and project organization by features
- How to structure modules and child routes with go_router
- Practical case: task list with details and creation screen
- Complete comparison: Navigator vs Navigator 2.0 vs go_router
- What to choose according to your application's size and complexity
- Table of real-world use cases and recommended solutions
- Best practices to avoid common navigation mistakes
- Best practices and patterns for robust navigation
- Organizing routes by modules or features
- State management in navigation flows
- Code cleanups and recommended architecture
- Why you shouldn't use push() and should use pushReplacement() when navigating between courses
- The problem with Navigator.push()
- Solution with Navigator.pushReplacement()
- Frequently Asked Questions about Navigation in Flutter
- Conclusion: how to choose the best navigation strategy for your Flutter app
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()andcontext.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
redirectparameter ingo_router, or via custom guards inRouterDelegateif you are using Navigator 2.0 directly.
- Using the
- How to pass additional data to a route without exposing it in the URL?
- Use the
extraparameter ingo_router: context.goNamed('detalle', extra: {'modo': 'edicion'});- Keep in mind that
extradoes not survive a page reload in Flutter Web, so for data that must persist in the URL, usepathParametersorqueryParameters.
- Use the
- How to control history in Flutter Web?
- Use declarative navigation (
go_routeror Navigator 2.0) and avoid manipulating the stack manually withpush/pop. Browser history will automatically sync with your route stack.
- Use declarative navigation (
- 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) => falseas the predicate, or up to a specific route withModalRoute.withName('/home').
- 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
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.