The Definitive Guide to Navigator 2.0 in Flutter

The Definitive Guide to Navigator 2.0 in Flutter

Navigator 2.0 is the declarative way to do navigation in Flutter. Learn how it works and why you should use it for your next app.

Navigator 2 thumbnail

Flutter has two ways to do navigation. The original navigation (Navigator 1.0) wasn’t very complete, so Flutter introduced a new way to do navigation: Navigator 2.0. And the reception was probably not what they expected.

Navigator 2.0 Feedback

Because of this, there is very little information online about how it works. Even Flutter’s documentation barely mentions Navigator 2.0. There are 9 documents about navigation, and Navigator 2.0 is mentioned only once, without any explanation of how it works. It seems like Flutter is trying to hide that they ever made this.

Flutter Navigation Docs

In this guide, we will cover everything you need to know about Navigator 2.0.

  • Why was it so disliked?
  • How does it work?
  • Why I believe you should still use it.

Why Navigator 2.0

When Flutter was originally released in 2018 it had one navigation system. We will refer to this as Navigator 1.0. The core problem the Flutter framework was built to solve is the ability to build natively compiled apps using one code base for any operating system. While the vision was to be able to support web and desktop applications, in the early days it only supported Android and iOS.

Navigator 1.0 is good enough for these two platforms since mobile applications don’t require very complicated navigation and Navigator 1.0 is simple and easy to use.

Navigation Stack

The core concept is the navigation stack. As a user, you see the very top layer of the stack, and to change the page you are looking at, you can push views on top of the stack and see a new page or you can pop views off of the stack and see the previous page.

That is the key concept of Navigator 1.0. It still had its flaws like deep linking, nested navigation, and not being declarative like the rest of Flutter, but people were able to look past those flaws and find workarounds.

Life was sunshine and rainbows during those times, but then Flutter Web became a focus for the team. Flutter Web brought in even bigger and more noticeable navigation problems like the URLs not being synced to the navigation stack and the browser back and forward buttons not working. For Flutter to become a true cross-platform framework, navigation would need a complete rework.

The biggest shift that needed to happen, was that navigation needed to be declarative, instead of imperative.

Imperative vs Declarative

The Flutter framework is built to be a declarative framework. That means that the way you write Flutter code is that you describe what the user should see based on a state. Then, the Flutter framework takes care of building the UI based on the declaration.

For example, when we want to show a text on the screen, we declare what we want to see, and Flutter just builds it.

Text(isLoggedIn ? 'Welcome back!' : 'Please log in');

Where as imperative you have to tell the system to do things step-by-step. For example, if Flutter was an imperative framework, the way to display a text would look like the code below. Instead of writing what you want to see, you are telling the system what it should do.

if (isLoggedIn) {
textView.setText("Welcome back!");
} else {
textView.setText("Please log in");
}

The above code is not code that you can write in Flutter. However, some parts of Flutter don’t follow the declarative approach including Navigator 1.0.

The widget that handles navigation in Flutter is called Navigator. This can be defined manually, but it is also defined within a MaterialApp. The way navigation works in Navigator 1.0, is you find the Navigator within your app using Navigator.of(context), and then call a function like push or pop to add a route into the navigation stack. This is very similar to our silly textView.setText example.

This approach does not match how the rest of Flutter works, so in Flutter version 1.22 a declarative Navigator 2.0 was introduced.

Navigator 2.0 makes navigation declarative. You no longer have functions to push and pop into the navigation stack, but instead, you control the navigation stack and define what the stack should look like at any point.

Great! Problem solved, right?

Technically, yes. But then why was it so poorly received?

Mostly because it is complex to implement because of these 2 reasons:

  1. The Flutter team plans to support both Navigator 1.0 and Navigator 2.0 so there are edge cases and caveats to make them work. And the guides they posted had to explain both.
  2. There are not many guides.

So that’s what we’re setting out to do here: create an in-depth guide to explain Navigator 2.0.

Navigator 2.0 introduces two main widgets: Page and Router. With Navigator 1.0 we had to push a MaterialPageRoute onto the stack, now in Navigator 2.0 we define the stack ourselves. This stack is made of a list of Page widgets. Then the Router widget does the manipulation of the stack. That’s the beauty of Navigator 2.0. There’s no hidden functionality behind navigation. It’s just a stack of Page widgets that you manipulate.

In concept, it’s straightforward, but as you work with it, you realize how complicated navigation in general is. Especially when you need to support apps and websites.

What is happening in 1.0

Before we keep going I want to fully cover what Navigator 1.0 is and how it works.

Note: We will only talk about MaterialApp as an example, but there are CupertinoApp equivalents for everything. (Ex. MaterialPage and CupertinoPage).

The MaterialApp widget that you create at the root of your application is doing a lot of things under the hood that make your apps work nicely. One of those is it’s creating a navigator for you. You can get access to this navigator in your application using Navigator.of(context).

This navigator internally creates a list of routes. You can add to this list of routes using the push method and you can remove routes using the pop method. If you look at the widget that we push, it is a route.

Navigator.of(context).push(MaterialPageRoute(builder: (context) => const BooksScreen());

Pages API

A Page is basically that underlying route that is being maintained by the Navigator. So instead of only being able to push and pop routes, you set the routes.

Below is an example showcasing this. You can set the pages directly within the Navigator and these will be the routes that your app has. The code below will start your application with a navigation stack of 3 widgets. Then you can use the same navigator to pop the pages or to push new pages onto it.

MaterialApp(
home: const Navigator(
pages: [
MaterialPage(child: HomeScreen()),
MaterialPage(child: BooksScreen()),
MaterialPage(child: SettingsScreen()),
],
),
);

If you are building a simple mobile app, this might be all that you need. You can manage the pages using state management, and you would have a declarative way to manage your navigation with full control over the navigation stack.

The main things you would miss are more apparent when working with the web: deep linking and syncing browser URLs.

Router API

This is the part where it gets a little complicated, but it’s not Flutter’s fault. It’s mostly due to all the little nuances that come with browsers.

With Navigator 1.0 we had a Navigator widget that would handle the navigation within your app. We still have that same Navigator widget, however it is defined within the Router widget. You can declare the Router yourself, like this.

MaterialApp(
home: Router(
routerDelegate: _routerDelegate,
routeInformationParser: _routeInformationParser,
),
);

Or in a similar way that MaterialApp contains a Navigator, a MaterialApp.router contains a Router.

MaterialApp.router(
routerDelegate: _routerDelegate,
routeInformationParser: _routeInformationParser,
);

There are two parts that a router needs to have full web functionality: RouterDelegate and RouteInformationParser

RouterDelegate

In the Pages API example, we showed how you can set the pages directly in a Navigator. When you use a Router, you do the same thing just within the build method of the RouterDelegate.

Navigator(
pages: [
MaterialPage(child: HomeScreen()),
MaterialPage(child: BooksScreen()),
MaterialPage(child: SettingsScreen()),
],
),

Now the complexity comes in when you have to manage these pages. When you use a browser, the routing information is the URL. How do you transform the URL into a Page and vice versa?

To start, we need a state variable to be the source of truth for our navigation stack and somewhere to define what is a valid path or not. Since a user can type anything they want into a search bar, your app needs to handle this by showing some default router.

For demo purposes, we will use Strings (the URLS) as our state object and source of truth. This will help keep things simple.

First, we need to define all our valid routes and include a route that we send all unknown routes to (in this case /404.

final List<String> routes = [
'/',
'/books',
'/settings',
'/404',
];

Then, we need to maintain a state for the current navigation stack. You can use any state management approach that you choose, but we will use ValueNotifier (with the MVVM architecture that we teach in the Best Flutter Course). This ValueNotifier will be the source of truth for our navigation stack, and we will create a class called RouteService that will be the only place where this navigation stack can get manipulated.

class RouterService {
final navigationStack = ValueNotifier<List<String>>(['/']);
void push(String path) {
if (path == navigationStack.value.last) {
return;
}
navigationStack.value = [...navigationStack.value, path];
}
void pop() {
navigationStack.value = navigationStack.value.sublist(
0,
navigationStack.value.length - 1,
);
}
void replaceAll(String path) {
navigationStack.value = [path];
}
}

We initialize the stack with our home route, which for most apps should be '/'. And there will be 3 methods that we have: push, pop, and replaceAll.

Note: I named these functions push and pop because they still represent what is happening to the navigation stack well. The route service is now imperative to make it easy to use in our app, but the logic within is still declarative.

Remember, the navigation is just a stack, so you can have any functions you want here. You are not limited to push and pop, and you can call them anything and do anything with them like removeOnlyTheThirdPage or add100RandomPagesInTheMiddle.

At this point, we have the navigation stack defined and functions to manipulate it. Now, we need to create our Page widgets from this and pass them into the Navigator within the RouterDelegate. To do this we will need access to the service. You can do this using whatever dependency injection you want. We will use a custom-built locator that is similar to get_it.

Then you build the list of pages based on the current navigation stack and pass it into the Navigator.

List<Page<dynamic>> createPages() {
List<Page<dynamic>> pages = [];
final navigationStack = _routerService.navigationStack.value;
for (int index = 0; index < navigationStack.length; index++) {
final route = navigationStack[index];
switch (route) {
case '/':
pages.add(
MaterialPage(key: ValueKey('Page_$index'), child: HomeScreen()),
);
case '/books':
pages.add(
MaterialPage(key: ValueKey('Page_$index'), child: BooksScreen()),
);
case '/settings':
pages.add(
MaterialPage(key: ValueKey('Page_$index'), child: SettingsScreen()),
);
case '/404':
pages.add(
MaterialPage(key: ValueKey('Page_$index'), child: UnknownScreen()),
);
}
}
return pages;
}

We also need our RouterDelegate to listen to any updates in the navigation stack. You will notice there are a few overrides that need to be implemented when using the RouterDelegate, two of those are addListener and removeListener. The addListener function allows us to listen to navigation stack changes and update our routes, and the removeListener will stop listening once this router is no longer used.

@override
void addListener(VoidCallback listener) {
_routerService.navigationStack.addListener(listener);
}
@override
void removeListener(VoidCallback listener) {
_routerService.navigationStack.removeListener(listener);
}

Now you can push and pop onto the stack, within your application! But only the push and pop methods from the routerService will work. The Navigator 1.0 pop method will not work. This is not necessarily a problem, but the default BackButton widget calls the original Navigator.of(context).pop(). You can still catch this using the onDidRemovePage method in the Navigator. Same thing with the browser back button. If either of those is pressed, this onDidRemovePage callback will be triggered and you can update your navigation stack to match.

Navigator(
key: navigatorKey,
pages: createPages(),
onDidRemovePage: (page) {
_routerService.pop();
},
);

The onDidRemovePage handles the back button within the application and the back button in the browser, but the system back buttons on iOS and Android are handled by the popRoute override within the RouterDelegate.

When the back button is triggered, we don’t want to remove it from our stack directly in the popRoute function. If we did that, it would remove our top route, which would trigger the onDidRemovePage, which would remove another route. Instead, if we just trigger the pop with the Navigator directly, we ensure that only the Navigator can do the pop.

But we only want to do this if our navigation stack has more than 1 item in the list because when we click the system back button on the root page, we want to pop out of the app (not handle it within our app). We let the device know that we didn’t handle the back button by returning a false. If we do handle it (like in most cases), we should return a true. And we wrap these returns in a SynchronousFuture because it’s slightly more performant.

@override
Future<bool> popRoute() async {
if (_routerService.navigationStack.value.length > 1) {
Navigator.of(navigatorKey.currentContext!).pop();
return SynchronousFuture(true);
}
return SynchronousFuture(false);
}

onPopPage vs onDidRemovePage This has been the most confusing point when working with Navigator 2. There is a deprecated method on the Navigator called onPopPage that seems to work better than onDidRemovePage.

The onPopPage method was called only when a pop was initiated within the system. This allowed us to pop the page directly from our service, instead of needing to use the navigator context to trigger it manually.

In fact, none of the navigation frameworks have been updated to use the new method. Not even go_router, which is maintained by Flutter! There are even a few issues opened on GitHub (#153122 and #160463) that have been open for a long time related to onDidRemovePage.

There is one more override that we must handle in the RouterDelegate called setNewRoutePath. This function is called whenever the operating system pushes a new route to our application. The most common case of this would be when someone types a URL directly in the address bar. In this case, we want to use our replaceAll function to create a new stack.

@override
Future<void> setNewRoutePath(String configuration) async {
_routerService.replaceAll(configuration);
}

At this point, the app should function as expected, at least on Android and iOS. If you check the web, you will notice a big issue: the URLs don’t work.

Without RouteInformationParser

Updating URL

There is one more key part that is missing for a complete navigation system on all platforms, and that’s syncing the URL in the browser to the navigation state. Two main behaviors need to work:

  1. When you type a URL in the browser, it should go to that page within the app.
  2. When you navigate to a page within the app, it should update the URL.

To do this you will need a RouteInformationParser. The RouteInformationParser is responsible for handling both of the above behaviors and it does so using two functions.

parseRouteInformation

The parseRouteInformation function handles the path where a user types in a new URL or a deep link. The goal of this function is to convert the URL into the state that we use for navigation. In our case, the state is a String (hence the return type), and it needs to be one of the user-defined routes. I wrote a helper function called findMatchingRoutePattern that looks through available routes, and returns the string value if it’s there, or returns a 404 if it’s not.

Once this is parsed, the setNewRoutePath that we defined in the RouterDelegate is called to replace the stack with this new state.

@override
Future<String> parseRouteInformation(
RouteInformation routeInformation,
) async {
final uri = routeInformation.uri;
return findMatchingRoutePattern(uri.toString(), routes);
}

restoreRouteInformation

The restoreRouteInformation handles the other case, where some action was taken within the application that requires the URL to be updated. For this to work, we will need another override in the RouterDelegate called currentConfiguration. This gets triggered whenever the routing changes within the application.

@override
String? get currentConfiguration {
if (_routerService.navigationStack.value.isEmpty) {
return null;
}
return _routerService.navigationStack.value.last;
}

The configuration returns a string type since that’s the state type of our current route. This configuration is then used to update the URL.

@override
RouteInformation? restoreRouteInformation(String configuration) {
if (configuration.isNotEmpty) {
return RouteInformation(uri: Uri.parse(configuration));
}
return null;
}

Now we have a complete navigation system using Navigator 2.0, that is synchronized with the URL.

Recap

Everything we have covered so far is the fundamental logic of Navigator 2.0. I want to quickly summarize all of it before covering a few more topics.

Navigator 2.0 Complete

Navigator 2.0 is a declarative implementation of navigation in Flutter. What that means is that your navigation stack is defined by you. The navigation stack is a state containing a list of data that defines your routes. It is defined by you (we used Strings in our example) and you are in charge of manipulating that list. The RouterDelegate is in charge of listening to changes in this list, and building the routes (Page widgets) to be displayed for the user. The RouterDelegate also has functions that notify or give information on route changes:

  • onDidRemovePage (within the Navigator) - triggered whenever a page is removed from the stack
  • popRoute - triggered whenever the system back button is pressed
  • setNewRoutePath - triggered when a new URL is requested by the system
  • currentConfiguration - triggered when the app routes change

Then the RouteInformationParser has two functions to convert from URL to app state and vice versa to keep the system and app synchronized.

Dynamic Routes

Everything we have discussed until now has been using static routes, because it simplifies all that you need to know about how Navigator 2 works. But dynamic routes are a very important piece of the puzzle, especially when working with Flutter Web.

The nice thing is that given all the fundamentals we learned, dynamic routes are quite easy. Remember the navigation system is a list of routes that you are defining. To have dynamic routes, you need a few helper functions to check for valid paths and extract the information from the URL and vice versa.

We based our entire example on strings, which can be good enough with helper functions to extract all the data. But we use a custom RouteEntry class that has a route definition and a builder function with some helper functions to make the whole process easier.

class RouteEntry {
RouteEntry({required this.path, required this.builder});
final String path;
final Widget Function(ValueKey<String>? key, RouteData routeData) builder;
}
class RouteData {
const RouteData({
required this.uri,
required this.routePattern,
this.extra,
});
final Uri uri;
final String routePattern;
final Object? extra;
String get pathWithParams => uri.toString();
Map<String, String> get queryParameters => uri.queryParameters;
Map<String, String> get pathParameters {
final params = <String, String>{};
final pathSegments = uri.pathSegments;
final patternSegments = getSegments(routePattern);
if (patternSegments.length != pathSegments.length) return params;
for (var i = 0; i < patternSegments.length; i++) {
if (patternSegments[i].startsWith(':')) {
final paramName = patternSegments[i].substring(1);
params[paramName] = pathSegments[i];
}
}
return params;
}
}

And our list of available routes now looks like this:

final List<RouteEntry> routes = [
RouteEntry(
path: '/',
builder: (key, routeData) {
return HomeScreen();
},
),
RouteEntry(
path: '/books',
builder: (key, routeData) {
return BooksScreen();
},
),
RouteEntry(
path: '/books/:bookId',
builder: (key, routeData) {
return BookDetailsScreen(bookId: routeData.pathParameters['bookId'] ?? '');
},
),
RouteEntry(
path: '/settings',
builder: (key, routeData) {
return SettingsScreen();
},
),
RouteEntry(
path: '/404',
builder: (key, routeData) {
return UnknownScreen();
},
),
];

Now using this as the baseline for your routes, update the RouterDelegate and RouteInformationParser to use this data class instead of strings.

Annoying Tips

There are a few issues we ran into that Flutter should have handled better, but since they don’t here they are.

RouterConfig

We have used MaterialApp.router for this demo, and honestly, it works great. However, you should be aware that there is a new routerConfig property that you can use. It acts the same way but allows you to define all the router-related things in one place.

At the moment of this article, the router config has worse defaults than MaterialApp.router. You have to manually provide a BackButtonDispatcher and a RouteInformationProvider, while the MaterialApp.router, has nice defaults.

How to get rid of # in the URL?

In my opinion, having a hashtag in the URL of your website does not look very nice, and is not user-friendly. To remove it, import the flutter_web_plugins package that is part of the flutter library and call setUrlStrategy(PathUrlStrategy()).

This will make your URLs nice, but it will also break your native applications. So you have to use this with a conditional import, so it only gets used if you are running the web platform.

What should you use?

At this point, you understand the fundamentals of Navigator 2. So should you use it?

For me, one thing is very clear: I should not be using Navigator 1. After working with a declarative approach for navigation, it just makes so much more sense. Even if I am not building applications with Flutter Web, being able to define my navigation stack feels so freeing and opens up possibilities that I wouldn’t have considered previously, because they would be a pain to implement.

I enjoy learning about the intricacies of how the native tools work, and I enjoy not having to depend on other’s code. Because of this, I choose to work with Navigator 2 directly for my applications.

However, it is time-consuming, and there are plenty of edge cases that I am sure to run into as I keep building. For some people, this is not worth the trade-off, especially when there are already tested solutions for declarative navigation.

Feedback for Flutter

I want to end this guide by addressing all the negativity that Navigator 2 has received. If anyone from the Flutter team reads this, here’s an outsider’s perspective of what went wrong and what could have been done better.

It’s very clear that navigation is a hard topic, and I can only imagine how difficult even getting to this solution must have been. The biggest complaint the community had was complexity, and yes, declarative navigation that supports both apps and websites was always going to be more complex, but there have been defaults provided since the initial release that reduced this complexity significantly. And the biggest complexity seems to come from still supporting Navigator 1.0.

After learning about Navigator 2.0, it’s very clear that it solves real issues that Navigator 1.0 had, and now Navigator 1.0 seems like unnecessary bloat. Yes, a full transition away from 1.0 would have been annoying, but the end state would have been much simpler.

And lastly, my biggest complaint is that it doesn’t seem like the team tried very hard to educate the community on Navigator 2.0. The reason I was compelled to write this massive guide is because of how difficult it was to find any resources explaining Navigator 2.0. Generally, the docs are incredible, and I use them to learn so much about Flutter, but the effort when it comes to navigation seems lackluster.

At the current state (4 years since Nav 2) release, you would not know Flutter can even support a browser back button or URL syncing from the documentation. And not how to do it.

Keep Learning

This guide covered most of the concepts that you need to know to get started with Navigator 2.0. Navigator 2.0 does go way deeper and you will need more upgrades to what we built here as your apps expand. Here are some of the things we’ve added to our navigation system:

  • A canPop to each page
  • Route definitions containing a builder to make dynamic links simpler
  • The navigation stack is a custom object containing helper functions and more information about the route
  • More functions for manipulating the navigation stack
  • A navigation observer, so you can use this with your analytics tooling
  • Lots of helper functions to clean up the code

If you want to see the full implementation that we use to build production-ready applications you can find it in our Flutter Boilerplate.

If you enjoyed this guide, you will enjoy this other guide how we implement MVVM Architecture in our production applications.