Chapters

Hide chapters

Flutter Apprentice

Fourth Edition · Flutter 3.16.9 · Dart 3.2.6 · Android Studio 2023.1.1

Section II: Everything’s a Widget

Section 2: 5 chapters
Show chapters Hide chapters

Section IV: Networking, Persistence & State

Section 4: 6 chapters
Show chapters Hide chapters

8. Routes & Navigation
Written by Vincent Ngo

Navigation, or how users switch between screens, is an important concept to master. Good navigation keeps your app organized and helps users find their way around without getting frustrated.

In the previous chapter, you got a taste of navigation where users tapped on a restaurant to view its menu items as shown below:

But this uses the imperative style of navigation, known as Navigator 1.0. In this chapter, you’ll learn to navigate between screens the declarative way.

You’ll cover the following topics:

  • Overview of Navigator 1.0.
  • Overview of Router API.
  • How to use go_router to handle routes and navigation.

By the end of this chapter, you’ll have everything you need to navigate to different screens!

Note: If you’d like to skip straight to the code, jump to Getting Started. If you’d like to learn the theory first, read on!

Introducing Navigation

If you come from an iOS background, you might be familiar with UINavigationController from UIKit, or NavigationStack from SwiftUI.

In Android, you use Jetpack Navigation to manage various fragments.

In Flutter, you use a Navigator widget to manage your screens or pages. Think of screens and pages as routes.

Note: This chapter uses these terms interchangeably because they all mean the same thing.

A stack is a data structure that manages pages. You insert the elements last-in, first-out (LIFO), and only the element at the top of the stack is visible to the user.

For example, when a user views a list of restaurants, tapping a restaurant pushes RestaurantPage to the top of the stack. Once the user finishes making changes, you pop it off the stack.

Here’s a top-level and a side-level view of the navigation stack:

LoginScreen Home LoginScreen Home RestaurantPage RestaurantPage Pop Push Navigator.push() Home RestaurantPage

Now, it’s time for a quick overview of Navigator 1.0.

Navigator 1.0 Overview

Before Flutter 1.22, you could only shift between screens by issuing direct commands like “show this now” or “remove the current screen and go back to the previous one”. Navigator 1.0 provides a simple set of APIs to navigate between screens. The most common ones are:

  • push(): Adds a new route on the stack.
  • pop(): Removes a route from the stack.

So, how do you add a navigator to your app?

Most Flutter apps start with WidgetsApp as the root widget.

Note: So far, you’ve used MaterialApp, which extends WidgetsApp.

WidgetsApp wraps many other common widgets that your app requires. Among these wrapped widgets there’s a top-level Navigator to manage the pages you push and pop.

Pushing and Popping Routes

To show the user another screen, you need to push a Route onto the Navigator stack using Navigator.push(context). Here’s an example:

bool result = await Navigator.push<bool>(
  context,
  MaterialPageRoute<bool>(
    builder: (BuildContext context) =>RestaurantPage(
      restaurant: restaurants[index],
      cartManager: cartManager,
      ordersManager: orderManager,
    )
  ),
);

Here, MaterialPageRoute returns an instance of your new screen widget. Navigator returns the result of the push whenever the screen pops off the stack.

Here’s how you pop a route off the stack:

Navigator.pop(context);

This seems easy enough. So why not just use Navigator 1.0? Well, it has a few disadvantages.

Navigator 1.0’s Disadvantages

The imperative API may seem natural and easy to use, but, in practice, it’s hard to manage and scale.

There’s no good way to manage your pages without keeping a mental map of where you push and pop a screen.

Widget Widget Widget Widget Widget Widget Widget Widget Widget Widget Data push() push() push() push() push()

Imagine a new developer joining your team. Where do they even start? They’d surely be confused.

Moreover, Navigator 1.0 doesn’t expose the route stack to developers. It’s difficult to handle complicated cases, like adding and removing a screen between pages.

For example, in Yummy, you only want to show the Onboarding screen if the user hasn’t completed the onboarding yet. Handling that with Navigator 1.0 is complicated.

LoginPage OnboardingScreen Home RestaurantPage How to remove?

Another disadvantage is that Navigator 1.0 doesn’t update the web URL path. When you go to a new page, you only see the base URL, like this: www.localhost:8000/#/. Additionally, the web browser’s forward and backward buttons may not work as expected.

Finally, the Back button on Android devices might not work with Navigator 1.0 when you have nested navigators or add Flutter to your host Android app.

Wouldn’t it be great to have a declarative API that solves most of these pain points? That’s why Router API was designed!

To learn more about Navigator 1.0, check out the Flutter documentation.

Router API Overview

Flutter 1.22 introduced the Router API, a new declarative API that lets you control your navigation stack completely. Also known as Navigator 2.0, the Router API aims to feel more Flutter-like while solving the pain points of Navigator 1.0. Its main goals include:

  • Exposing the navigator’s page stack: You can now manipulate and manage your page routes. More power, more control!
  • Backward compatibility with imperative API: You can use imperative and declarative styles in the same app.
  • Handling operating system events: It works better with events like the Android and Web system’s Back button.
  • Managing nested navigators: It gives you control over which navigator has priority.
  • Managing navigation state: You can parse routes and handle web URLs and deep linking.

Here are the new abstractions that make up Router’s declarative API:

Pop route Modifies based on System notifications Requests changes to Navigator Rebuild Get newly configured Navigator for rebuild Back button pressed Set initial route Set new route Initial route New intent Operating System Router Delegate Router (Widget) BackButton Dispatcher RouteInformation Provider RouteInformation Parser App State

The new API includes the following key components:

  • Page: An abstract class that describes the configuration for a route.
  • Router: Handles configuring the list of pages the Navigator displays.
  • RouterDelegate: Defines how the router listens for changes to the app state to rebuild the navigator’s configuration.
  • RouteInformationProvider: Provides RouteInformation to the router. Route information contains the location info and state objects to configure your app.
  • RouteInformationParser: Parses route information into a user-defined data type.
  • BackButtonDispatcher: Reports presses on the platform system’s Back button to the router.
  • TransitionDelegate: Decides how pages transition into and out of the screen.

Note: This chapter will leverage a routing package, go_router, to make the Router API easier to use.

If you want to know how to use the vanilla version of the Router API, check out Edition 2.0 of this book.

Navigation and Unidirectional Data Flow

As discussed with Navigator 1.0, the imperative API is very basic. It forces you to place push() and pop() functions all over your widget hierarchy which couples all your widgets! To present another screen, you must place callbacks up the widget hierarchy.

With the new declarative API, you can manage your navigation state unidirectionally. The widgets are state-driven, as shown below:

MyApp Topmost route ... Route 2 Route 1 AppState Router Navigator User taps button 1. 2. Tap handler modifies app state 3. Notifies listener of state changes 4. Rebuilds and shows new route Button

Here’s how it works:

  1. A user taps a button.
  2. The button handler tells the app state to update.
  3. The router is a listener of the state, so it receives a notification when the state changes.
  4. Based on the new state changes, the router reconfigures the list of pages for the navigator.
  5. The navigator detects if there’s a new page in the list and handles the transitions to show the page.

That’s it! Instead of having to build a mental mind map of how every screen presents and dismisses, the state drives which pages appear.

Is Declarative Always Better Than Imperative?

You don’t have to migrate or convert your existing code to use the new API if you have an existing project.

Here are some tips to help you decide which is more beneficial for you:

  • For medium to large apps: Consider using a declarative API and a router widget when managing a lot of your navigation state.
  • For small apps: The imperative API is suitable for rapid prototyping or creating a small app for demos. Sometimes push and pop are all you need!

Next, you’ll get some hands-on experience with declarative navigation.

Note: To learn more about Navigator 1.0, check:

Getting Started

Open the starter project in Android Studio. Run flutter pub get and then run the app.

Note: It’s better to start with the starter project rather than continuing with the project from the last chapter because it contains some changes specific to this chapter.

You’ll see that Yummy only shows the Login screen. Of course, it also supports responsive UI on different devices!

Don’t worry. You’ll connect all the screens soon.

You’ll build a simple flow that features a login screen and an onboarding widget before showing the existing tab-based app you’ve made so far. But first, take a look at some changes to the project files.

Changes to the Project Files

Before you dive into navigation, there are new files in this starter project to help you out.

What’s New in the Screens Folder

There are new changes in lib/ and lib/screens/:

  • home.dart: Now includes a Profile button at the top-right for the user to view their profile.
  • screens.dart: A barrel file that groups all the screens into a single import.
  • login_page.dart: Lets the user log in.
  • account_page.dart: Lets users check their profile, update settings and log out.

Later, you’ll use these to construct your authentication UI flow.

What’s New in the Models Folder

There are three new model objects in lib/models/.

  • models.dart: A barrel file that groups all the models into a single import.
  • auth.dart: Manages user authentication state, whether they are login in or out.
  • user.dart: Describes a single user and includes information like the user’s role, profile picture, full name and app settings.

What’s New in the Components Folder

There is one change in lib/components/.

  • components.dart: A barrel file that groups all the components into a single import.

New Packages

There are three new packages in pubspec.yaml:

url_launcher: ^6.2.1
go_router: ^13.0.1
shared_preferences: ^2.2.2

Here’s what they do:

  • url_launcher: A cross-platform library to help launch a URL.
  • go_router: A package built to reduce the complexity of the Router API. It helps developers easily implement declarative navigation.
  • shared_preferences: Wraps platform-specific persistent storage for simple data. AppCache uses this package to store the login state.

Now that you know what’s changed, it’s time for a quick overview of the UI flow you’ll build in this chapter.

Looking Over the UI Flow

Here are the first two screens you show the user:

  1. When the user launches the app, he must log in by entering their username and password, then tap Login.
  2. Once the user logs in, the user goes to the app’s Home. They can now start using the app.

The app presents the user with three tabs with these options:

  1. Home: View restaurants, friend posts, and food categories.
  2. Orders: Track all orders submitted.
  3. Account: View the user’s profile and logout.

Next, the user can tap on a restaurant to view the menu to order food. They can select items to add to their cart and submit an order.

Once the order is submitted, the user is redirected to Orders tab:

On the Account screen, they can:

  • View their profile and see how many points they’ve earned.
  • Visit the Kodeco website.
  • Log out of the app.

Below you’ll see an example:

Your app is going to be awesome when it’s finished. Now it’s time to learn about go_router!

Introducing go_router

The Router API gives you more abstractions and control over your navigation stack. However, the API’s complexity and usability hindered a bit the developer experience.

Pop route Modifies based on System notifications Requests changes to Navigator Rebuild Get newly configured Navigator for rebuild Back button pressed Set initial route Set new route Initial route New intent Operating System Router Delegate Router (Widget) BackButton Dispatcher RouteInformation Provider RouteInformation Parser App State

For example, you must create your RouterDelegate, bundle your app state logic with your navigator and configure when to show each route.

To support the web platform or handle deep links, you must implement RouteInformationParser to parse route information.

Eventually, developers and even Google realized the same thing: creating these components wasn’t straightforward. As a result, developers wrote other routing packages to make the process easier.

Interesting Read: Google’s Flutter team came out with a research paper evaluating different routing packages. You can check it out here.

Of the many packages available, you’ll focus on GoRouter. Such a package, created by Chris Sells, is now fully maintained by the Flutter team. GoRouter aims to make it easier for developers to handle routing, letting them focus on building the best app they can.

In this chapter you’ll focus on how to:

  • Create routes.
  • Handle errors.
  • Redirect to another route.

Time to code!

Creating the go_router

Within main.dart, add the following import:

import 'package:go_router/go_router.dart';

Next locate the comment // TODO: Initialize GoRouter and replace it with the following:

// 1
late final _router = GoRouter(
  // 2
  initialLocation: '/login',
  // TODO: Add App Redirect
  // 3
  routes: [
    // TODO: Add Login Route
    // TODO: Add Home Route
  ],
  // TODO: Add Error Handler
);

Here’s how it works:

  1. Initializes an instance of GoRouter, a declarative router for Flutter.
  2. Sets the initial route that the app will navigate to. When the user opens the app they will navigate to the login page.
  3. routes contains a list of possible routes for the application. Each route will typically be defined with a path, builder or redirect function.

There are other configurations you can set such as app redirect, and error handling. For example, if the user is logged in it should redirect to home, or if the user enters a wrong path it should show an error or a 404 page.

Note on late final in Router Declaration: The late final keyword is used for the router to defer its initialization until necessary, such as waiting for user authentication. It ensures the router is non-nullable and remains constant once initialized, aligning with the needs of dependent states or objects in the app.

Using Your Router

Next, locate // TODO: Replace with Router. Replace it and the entire return MaterialApp(); code with:

// 1
return MaterialApp.router(
  debugShowCheckedModeBanner: false,
  // 2
  routerConfig: _router,
  // TODO: Add Custom Scroll Behavior
  title: 'Yummy',
  scrollBehavior: CustomScrollBehavior(),
  themeMode: themeMode,
  theme: ThemeData(
    colorSchemeSeed: colorSelected.color,
    useMaterial3: true,
    brightness: Brightness.light,
  ),
  darkTheme: ThemeData(
    colorSchemeSeed: colorSelected.color,
    useMaterial3: true,
    brightness: Brightness.dark,
  ),
);

Here’s how it works:

  1. MaterialApp.router. This constructor is used for apps with a navigator that uses a declarative routing approach. It takes a router configuration rather than a set of routes.
  2. routeConfig reads _router to know about navigation properties. This will help the MaterialApp to set up the essential parts of a router. Under the hood, it will configure routerDelegate, routeInformationParser, and routeInformationProvider.

Your router is all set!

Adding Screens

With all the infrastructure in place, it’s time to define which screen to display according to the route. But first, check out the current situation.

Build and run on iOS. You’ll notice an error screen exception:

If the route isn’t found, GoRouter provides a Page Not Found screen by default. That’s because you haven’t defined any routes yet!

Setting Up Your Error Handler

You can tweak GoRouter to show a custom error page. It’s common for users to enter the wrong URL path, especially with web apps. Web apps usually show a 404 error screen.

Next locate // TODO: Add Error Handler and replace it with:

errorPageBuilder: (context, state) {
  return MaterialPage(
    key: state.pageKey,
    child: Scaffold(
      body: Center(
        child: Text(
          state.error.toString(),
        ),
      ),
    ),
  );
},

Here you simply show your error page and the error exception.

Trigger a hot restart. Your custom error page now displays.

Next, you’ll start working on your login page.

Adding the Login Route

You’ll start by displaying the Login screen.

Locate // TODO: Add Login Route and replace it with:

GoRoute(
  // 1
  path: '/login',
  // 2
  builder: (context, state) =>
    // 3
    LoginPage(
      // 4
      onLogIn: (Credentials credentials) async {
        // 5
        _auth
          .signIn(credentials.username, credentials.password)
          // 6
          .then((_) => context.go('/${YummyTab.home.value}'));
    })),

Here’s how you define a route:

  1. The route is set to /login. When the URL or path matches /login go to the login route.
  2. The builder() function creates the widget to display when the user hits a route.
  3. The function returns a Login widget.
  4. The Login widget takes a callback named onLogIn which returns the user credentials.
  5. Use the credentials to log in.
  6. If the login is successful, navigate to the path /0, which is the first tab.

Trigger a hot restart. You’ll see the Login Page:

You just added your first route!

Adding the Home Route

Once you log in, you need to navigate to the home route. Locate the comment // TODO: Add Home Route and replace it with the following:

// 1
GoRoute(
  path: '/:tab',
  builder: (context, state) {
    // 2
  return Home(
    //3
    auth: _auth,
    //4
    cartManager: _cartManager,
    //5
    ordersManager: _orderManager,
    //6
    changeTheme: changeThemeMode,
    //7
    changeColor: changeColor,
    //8
    colorSelected: colorSelected,
    //9
    tab: int.tryParse(state.pathParameters['tab'] ?? '') ?? 0);
    },
    // 10
    routes: [
    // TODO: Add Restaurant Route
  ]),

Here’s how it works:

  1. The route is set to /. When the URL or path matches / go to the home route. :tab is a path parameter used to switch between different tabs.
  2. The builder function returns a Home widget.
  3. Pass auth for handling authentication
  4. Use cartManager to manage the items that the user added to the cart.
  5. Use ordersManager to manage all the orders submitted.
  6. Set a callback to handle user changes from light to dark mode.
  7. Set a callback to handle user app color theme changes.
  8. Pass the currently selected color theme.
  9. Set the current tab, default to 0 if the path parameter is absent or not an integer.

Perform a hot reload if needed, click the Login button and now you’ll land on Home.

Navigate to the Current Tab

Try clicking on the tab bar items and notice that nothing works. You’ll now add a way to navigate between tabs.

In lib/home.dart locate // TODO: Navigate to specific tab and replace it with the following:

context.go('/$index');

Don’t forget to import go_router:

import 'package:go_router/go_router.dart';

Now you can navigate to different tabs.

Hot reload again and notice that the app goes back to the login screen. Wouldn’t it be great when the user opens Yummy app again to go straight to the home page if the user is already logged in?

Handling Redirects

You redirect when you want your app to go to a different location. GoRouter lets you do this with its redirect handler.

Most apps require some type of login authentication flow, and redirect is perfect for this situation. For example, some of these scenarios may happen to your app:

  • The user logs out of the app.
  • The user tries to go to a restricted page that requires them to log in.
  • The user’s session token expires. In this case, they’re automatically logged out.

It would be nice to redirect the user back to the login screen in all these cases. Open lib/main.dart and locate the comment // TODO: Add Redirect Handler and replace it with:

// 1
Future<String?> _appRedirect(
  BuildContext context, GoRouterState state) async {
  // 2
  final loggedIn = await _auth.loggedIn;
  // 3
  final isOnLoginPage = state.matchedLocation == '/login';

  // 4
  // Go to /login if the user is not signed in
  if (!loggedIn) {
    return '/login';
  }
  // 5
  // Go to root if the user is already signed in
  else if (loggedIn && isOnLoginPage) {
    return '/${YummyTab.home.value}';
  }

  // 6
  // no redirect
  return null;
}

Here’s how it works:

  1. _appRedirect() is an asynchronous function that returns a future, optional string. It takes in a build context and the go router state.
  2. Get the login status.
  3. Check if the user is currently on the login page.
  4. If the user is not logged in yet, redirect to the login page.
  5. If the user is logged in and is on the login page, redirect to the home page.
  6. Don’t redirect if no condition is met.

Next to apply the handler, locate // TODO: Add App Redirect and replace it with:

redirect: _appRedirect,

Hot reload and you will notice that the app now goes to the home page directly.

Adding the Restaurant Route

When the user taps on a restaurant on the Explore page, the app navigates to a subroute. Locate // TODO: Add Restaurant Route and replace it with:

GoRoute(
  // 1
  path: 'restaurant/:id',
  builder: (context, state) {
    // 2
    final id =
        int.tryParse(state.pathParameters['id'] ?? '') ?? 0;
    // 3
    final restaurant = restaurants[id];
    // 4
    return RestaurantPage(
      restaurant: restaurant,
      cartManager: _cartManager,
      ordersManager: _orderManager,
    );
  }),

Here’s how it works:

  1. The route is defined with the path restaurant/:id. The :id part is a path parameter, which allows for dynamic routing based on the restaurant’s ID.
  2. Within the builder() function, you extract the id from pathParameters.
  3. Get the restaurant based on the `id``.
  4. Return the RestaurantPage widget with the specific restaurant, cart and order manager.

Now that you have set up the restaurant route, you need to navigate to it.

Navigate to the Restaurant Page

Open lib/components/restaurant_section.dart, locate the comment // TODO: Navigate to Restaurant and replace it with the following:

context.go('/${YummyTab.home.value}/restaurant/${restaurants[index].id}');

Don’t forget to add the necessary imports:

import 'package:go_router/go_router.dart';
import '../constants.dart';

From the home page, based on the selected restaurant, navigate to the specific restaurant with the specific restaurant id.

Note: There are two ways to navigate to different routes:

  1. context.go(path)
  2. context.goNamed(name)

You should use goNamed() instead of go() as it’s error-prone, and the actual URI format can change over time.

goNamed() performs a case-insensitive lookup by using the name parameter you set with each route. It also helps you pass query parameters to your route.

Navigate to the Order Page

Once a user adds items to the cart and submits an order, it would be nice to navigate to the orders tab, so that customers can review the order.

Open restaurant_page.dart and add the following imports:

import 'package:go_router/go_router.dart';
import '../constants.dart';

Next locate // TODO: Navigate to Orders Page and replace it with:

context.pop();
context.go('/${YummyTab.orders.value}');

Now, when the user taps on the Submit order button, the app navigates to the Orders tab.

Handle Log Out

Lastly you’ll work on the logout functionality.

Open home.dart, locate the // TODO: Logout and go to login and replace it with the following:

widget.auth.signOut().then((value) => context.go('/login'));

Here you call signOut(), which resets the entire app state and redirects you back to the Login screen.

Save your changes. Now, tap the Log out button on the Account screen. You’ll notice it goes back to the Login screen, as shown below:

Congratulations, you’ve now completed the entire UI navigation flow.

Key Points

  • Navigator 1.0 is useful for quick and simple prototypes, presenting alerts and dialogs.
  • Router API is useful when you need more control when managing the navigation stack.
  • GoRouter is a wrapper around the Router API that makes it easier for developers to build navigation logic.
  • With GoRouter, you navigate to other routes using goNamed() instead of go().
  • Use a router widget to listen to navigation state changes and configure your navigator’s list of pages.
  • If you need to navigate to another page after some state change, handle that with the redirect() handler.
  • You can customize the error page by implementing the errorPageBuilder.

Where to Go From Here?

You’ve now learned how to navigate between screens the declarative way. Instead of calling push() and pop() in different widgets, you use multiple managers to manage your state.

You also learned how to create a GoRouter widget, which encapsulates and configures all the routes for a navigator. Now, you can easily manage your navigation flow in a single router object!

To learn about navigation in Flutter, here are some recommendations:

Other Libraries to Check Out

GoRouter is just one of the many libraries trying to make the Router API easier to use. Check them out here:

There are so many more things you can do with Router API. In the next chapter, you’ll look at supporting web URLs and deep linking!

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.