Navigation in Flutter
This article provides insights into how Navigator and router-based packages work but sticks to the essentials. Along the way, I’ve also shared my personal thoughts and ideas on implementing navigation in Flutter, along with my vision for how it all comes together in practice.
Table of Contents
Navigator
The Navigator is a key part of how apps manage and display routes. Think of it as the tool that keeps track of where you are in the app, shows the right screen at the right time, and handles moving between screens smoothly.
The Navigator relies on Overlay, which functions like a stack of layers. It allows you to stack elements on top of each other, such as popups, tooltips, or notifications over the main screen.
Applications can use multiple Navigators, and it’s common practice to have a separate Navigator for each tab to enable nested navigation. This approach helps manage navigation independently within different parts of the app.
Each page, dialog or a bottom sheet in an app is called a Route, and the Navigator uses these routes to determine what gets displayed. A Route is responsible for adding its content (like a screen or dialog) to the stack of layers managed by the Overlay. Routes follow a clear hierarchy:
- OverlayRoute: A route that requires its descendants to add their content to the Overlay.
- TransitionRoute: Extends OverlayRoute and supports animations. When added, it starts a forward animation, and when popped, it triggers a reverse animation.
- ModalRoute: Extends TransitionRoute and blocks interaction with earlier routes. If its opaque property is true, it covers the entire Navigator.
- PageRoute: Extends ModalRoute, typically with opaque set to true, ensuring it covers the whole screen. Subclasses like MaterialPageRoute and CupertinoPageRoute add platform-specific transitions and gestures.
Additionally, Route manages its state, handling actions like pushes, pops, cleanup (disposal), and returning results when popped.
Imperative API
Routes are used directly by Navigator Imperative API (push, replace):
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const OtherScreen(),
),
);Under the hood, Navigator manages a stack of routes (called the history). When a route is pushed, it’s added to the top of the stack, triggering logic to display it. Similarly, popping a route removes it from the stack.
As the stack changes, Navigator updates the state of the Overlay, which handles rendering the widgets. For instance, when a route is pushed, its corresponding OverlayEntries are attached to the root overlay.
This API is clean and straightforward, making it well-suited for simple use cases. However, its simplicity comes at a cost when dealing with more advanced or complex scenarios. As we lack full control over the stack and its lifecycle, implementing features like deep linking, navigation guards, or custom behaviors can become challenging and cumbersome.
Pages API
To address these challenges, the Pages (or declarative) API was introduced. Instead of relying on methods like push or pop, it works with a stack of pages:
Navigator(
pages: [
MaterialPage(
child: OtherScreen(),
)
],
)Navigator Pages API
A page is an abstraction of a route that the Navigator uses to create actual routes and add them to its history stack. Notably, these page-based routes are still added to the same history stack, allowing both the declarative Pages API and the traditional imperative API to be used simultaneously.
The Pages API introduced more granular control over the navigation stack, unlocking powerful new capabilities. One key improvement is the ability to create routes declaratively, where routes are built dynamically based on the application’s state. This approach simplifies complex navigation scenarios and makes the process more predictable.
Synchronization with Platform
Another crucial aspect of navigation is ensuring the path stays synchronized with the platform. This ensures that any changes to the path, such as through a deep link or updates in the browser’s URL bar, remain in sync with the running application.
For aforementioned reasons 3 components were developed:
- RouteInformationProvider: A class responsible for retrieving and sending route information to the platform.
- RouteInformationParser: A class that translates route information into a configuration and vice versa.
- RouterDelegate: A class that manages the core navigation logic, handles push/pop route intents, notifies about configuration changes and builds a navigating widget, typically the Navigator.
These components are tied together by the Router widget, which establishes bidirectional communication between the Flutter app and the underlying platform. It ensures synchronization and notifies the relevant components of any changes. This is created by WidgetsApp.router and by wrappers like MaterialApp.router/CupertinoApp.router.
Using these APIs directly can be cumbersome as it requires a lot of difficult code to function properly. To simplify this process, many Router-based libraries have been developed since that time, each designed to tackle common navigation challenges:
- Enabling seamless navigation experiences.
- Supporting advanced features like nested navigation, guards, and deep linking.
- Synchronizing the navigation path with the underlying platform, such as updating the browser’s URL.
My thoughts
Navigation is a fascinating and essential part of any application. In today’s fast-paced digital world, ensuring smooth navigation is critical to keeping users engaged. Slow-loading pages or broken links can lead to higher churn rates and lower user retention.
To tackle this, we need a reliable navigation system. Instead of building one from scratch, it’s usually more efficient to choose from existing solutions. Over the years, I’ve worked with various packages like auto_route, go_router, beamer, and native navigation APIs combined with uni_links.
From my experience, go_router stands out as the best choice, especially when paired with go_route_builder, which makes routing both seamless and type-safe. Another great feature of go_router is its support for guards, which are incredibly useful. I’ve built several apps with authentication flows using go_router guards and even written a few articles (here and here) about them, which you might find helpful!
Another important consideration is that not all routes can be fully declarative (defined directly in your navigation library). Sometimes, you need to push routes dynamically (e.g., using Navigator.push) for cases like alert dialogs, date pickers, or bottom sheets. My approach is to combine both APIs—using go_router for structured, declarative navigation and Navigator.push/Navigator.pop for dynamic, on-the-fly routes—depending on the specific use case.
Ultimately, the choice of routing approach depends on the project’s specific requirements and the team’s preferences. It’s also important to spend time carefully designing route paths and flows. However, in my experience, go_router is sufficient for most applications. In fact, the most popular apps—like YouTube, GitHub, Amazon—follow a similar routing philosophy.
EOF.