Mert Özen Further With Every Line
Frontend Mobile with Flutter — #12

Navigation in Flutter: Navigator, Declarative Routing with go_router, Deep Links and Auth Guards with redirect

Mert Özen Nov 15, 2026 10 min 4 views
Navigation in Flutter: Navigator, Declarative Routing with go_router, Deep Links and Auth Guards with redirect

In go_router, context.go goes to a URL and rebuilds the stack from the URL's hierarchy, while context.push adds to the current stack and can return a result. With real test and Chromium output we add to Bookstore a bottom navigation where each tab keeps its own stack with StatefulShellRoute, the /catalog/books/:isbn deep link, a redirect auth guard that reruns by itself through refreshListenable, and PopScope to keep a form from being lost.

Version note: The examples in this post were run with Flutter 3.47.8 (Dart 3.13.7), material_ui 1.11.0, provider 6.1.5+1 and go_router 18.1.4. The output comes from flutter test runs and from opening the web build in Playwright's headless Chromium; setting up Android App Links and iOS Universal Links needs a device, a domain and a signing key, so it was not run and is described from the docs.

Guess First

In the mobile app of Bookstore, the online bookstore used as the shared example across this site's series, the user is on Dune's details page. There are two ways to open the sign-in screen:

router.go('/sign-in');
// or
router.push('/sign-in');

Both show the sign-in form on the screen. When the user presses the back button, in which case do they return to Dune? In one they do; in the other there is not even a page to go back to. Which is which, and why does the difference matter?

Flutter's basic navigation tool is Navigator: it keeps screens in a stack, push adds a route on top, pop removes the top one. Until now we opened the details and cart screens with Navigator.push. push returns a Future, and that Future completes with the given value when the opened route is closed with pop. Dialogs and bottom sheets are routes too; the sheet that asks for a quantity when "Add to cart" is tapped on the details page uses exactly this:

Future<void> _addToCart(Book book) async {
  // A bottom sheet is a route too: pop(context, 2) completes this Future
  final quantity = await showModalBottomSheet<int>(
    context: context,
    builder: (context) => Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        for (var n = 1; n <= 3; n++)
          ListTile(
            title: Text('$n'),
            onTap: () => Navigator.of(context).pop(n),
          ),
      ],
    ),
  );
  if (quantity == null || !mounted) return; // dismissed
  final cart = context.read<CartViewModel>();
  await cart.setQuantity.execute((
    book: book,
    quantity: cart.quantityOf(book) + quantity,
  ));
}

If the user closes the sheet by tapping outside it, the result is null; that is why the type is handled as int?. The mounted check from the sixth post is here again after the await.

For moving between screens, though, Navigator.push has two limits. The first is the URL: getting to a specific page of the app from a link in a notification or an email is hard in a structure built from chains of pushes. The second is state: rules such as "do not go to orders unless signed in" have to be written separately before every push call. That is why the Flutter docs recommend a routing package for most apps; the package recommended in the architecture guide is go_router.

Declarative Routing with go_router

In go_router, screens are defined as a tree of URLs; the URL decides which screen the app is on. It is very similar to the route config in Angular. The first half of Bookstore's router:

GoRouter createRouter({
  required AuthRepository auth,
  String? initialLocation,
}) => GoRouter(
  initialLocation: initialLocation ?? Routes.catalog,
  // Sign-in and sign-out run redirect again
  refreshListenable: auth,
  redirect: (context, state) =>
      authRedirect(state.uri, signedIn: auth.isSignedIn),
  routes: [
    GoRoute(path: '/', redirect: (_, _) => Routes.catalog),
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) =>
          ScaffoldWithNavBar(navigationShell: navigationShell),
      branches: [
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: Routes.catalog,
              builder: (context, state) => const HomeScreen(),
              routes: [
                GoRoute(
                  path: 'books/:isbn',
                  builder: (context, state) =>
                      BookDetailsRoute(isbn: state.pathParameters['isbn']!),
                ),
              ],
            ),
          ],
        ),

The routes are /catalog, /catalog/books/:isbn, /cart, /cart/address, /orders and /sign-in. The paths are constants in the Routes class; no URL is built from strings inside the code. MaterialApp is now MaterialApp.router(routerConfig: _router). The router is created once, in the State of BookstoreApp: a GoRouter created inside build resets navigation every time the app rebuilds.

The details page no longer takes a Book object; it takes the ISBN from the URL and loads the book from the repository with BookDetailsViewModel. That way the page can be opened not only from the catalog but also from an outside link. In a test we started the app directly with this URL:

location: /catalog/books/9780441172719, title: true, can pop: true
after back: /catalog

The app opened on the details page without going through the catalog, yet you can go back, and going back brings the catalog. go_router built the stack itself from the URL's hierarchy: because /catalog/books/... is a child route of /catalog, the catalog page was placed under it.

StatefulShellRoute: Each Tab Keeps Its Own Stack

The bottom navigation has three tabs: Catalog, Cart, Orders. If the user opens a book's details in the catalog, switches to the cart and then comes back to the catalog, they expect to find the details page where they left it. StatefulShellRoute.indexedStack keeps a separate Navigator for each branch and keeps the tabs alive inside an IndexedStack. The shell widget:

/// The bottom navigation around the three tabs; each tab keeps its own stack.
class ScaffoldWithNavBar extends StatelessWidget {
  const ScaffoldWithNavBar({super.key, required this.navigationShell});

  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: NavigationBar(
        selectedIndex: navigationShell.currentIndex,
        // Tapping the current tab again goes back to its first page
        onDestinationSelected: (index) => navigationShell.goBranch(
          index,
          initialLocation: index == navigationShell.currentIndex,
        ),
tap Cart tab -> /cart
tap Catalog tab -> /catalog/books/9780441172719
tap Catalog tab again -> /catalog

The catalog tab remembered the details page; a second tap on the selected tab went back to the start of the tab, the behavior iOS and Android users are used to. The badge on the cart tab's icon is rebuilt with the Selector from the previous post only when the number of books changes. Because the tabs are kept alive, the catalog's CatalogViewModel is not lost when the tab changes; the scroll position and the loaded pages stay in place.

An Auth Guard with redirect

Orders and the delivery address are for signed-in users. In go_router the place for this is redirect: it runs before every navigation; returning null allows the move, returning a path sends the user there. We wrote the rule as a plain function, independent of widgets:

/// The auth guard, as a plain function so it can be unit tested.
String? authRedirect(Uri uri, {required bool signedIn}) {
  final path = uri.path;
  final needsSignIn = path.startsWith(Routes.orders) || path == Routes.address;
  if (!signedIn && needsSignIn) {
    return Uri(
      path: Routes.signIn,
      queryParameters: {'from': uri.toString()},
    ).toString();
  }
  if (signedIn && path == Routes.signIn) {
    final from = uri.queryParameters['from'];
    // Only a path inside the app: "/orders" yes, "//other.example" no
    final isLocal =
        from != null && from.startsWith('/') && !from.startsWith('//');
    return isLocal ? from : Routes.catalog;
  }
  return null;
}
/orders (signed in: false) -> /sign-in?from=%2Forders
/cart/address (signed in: false) -> /sign-in?from=%2Fcart%2Faddress
/catalog/books/9780441172719 (signed in: false) -> stays
/sign-in?from=%2Forders (signed in: true) -> /orders
/sign-in?from=%2F%2Fother.example (signed in: true) -> /catalog

It is the counterpart of the "Angular Guards and Lazy Loading: CanActivate, CanDeactivate, CanLoad and Resolve" post of the Angular series: redirect works like a CanActivate that returns a path instead of a UrlTree. The difference is that re-evaluation is automatic: because we passed refreshListenable: auth, the router runs redirect again for the current URL when AuthRepository notifies (sign-in or sign-out). When the sign-in form is submitted successfully, nobody calls go; thanks to redirect, the user goes back to where they came from:

signed out, go /orders -> /sign-in?from=%2Forders
after sign-in -> /orders, BK-1042 shown: true

The check on the from parameter is a security measure. This parameter can be filled in by an outside link; without the check, the user could be sent to a place chosen by an attacker after signing in (an open redirect). It is the mobile counterpart of the vulnerability covered in the Web Security series. go_router 18 also has an onEnter callback: it runs before redirects on every navigation and can block the move; the package docs call redirect "legacy" but keep supporting it.

go or push? Results and Losing a Form

The answer to "Guess First" is in the test:

go(/sign-in): can pop false
push(/sign-in): can pop true
after back: /catalog/books/9780441172719

go goes to the URL and rebuilds the stack from that URL's hierarchy. /sign-in is a top-level route, so there is nothing under it; the back button leaves the app. push adds on top of the current stack; the back button returns to Dune. The rule: use go to "go" somewhere else (switch tabs, open a link), and push to do a task and come back (sign in, pick an address, confirm). Returning a result is only possible with push: the cart screen opens the delivery address form with context.push<DeliveryAddress>(Routes.address), the form calls context.pop(address) when it succeeds, and the address goes back to the cart screen.

One more problem with the form: if the user has typed half the address and accidentally presses the back button or swipes from the edge, what they typed is lost. PopScope prevents this:

// Back button and back gesture ask first when the user has typed something
return PopScope<Object?>(
  canPop: !_dirty,
  onPopInvokedWithResult: (didPop, result) async {
    if (didPop) return;
    if (await _confirmDiscard() && context.mounted) {
      Navigator.of(context).pop();
    }
  },
back with text: dialog 1
push result: DeliveryAddress, location /cart

_dirty is true when any of the controllers has text. The back button opened the "Discard this address?" dialog; when we filled in the form and submitted it, context.pop(address) was not stopped by PopScope, because canPop affects only the system back button and back gesture, not a pop that the code calls explicitly. The reason PopScope replaced the old WillPopScope is Android's predictive back (a preview of the previous screen during the back gesture): the system needs to know whether it can go back before the gesture starts.

A deep link means that tapping a web address (for example https://bookstore.example.com/catalog/books/9780441172719) opens the matching page of the app instead of the browser. The router side is ready: go_router takes the path of the incoming URL and matches it against the routes above. Since Flutter 3.27, Flutter's own deep link handler is on by default; if you use another deep link package, you have to turn it off with flutter_deeplinking_enabled on Android and FlutterDeepLinkingEnabled on iOS. On the platform side there are two jobs, and both require owning the domain:

  • Android App Links: an intent-filter on the activity in AndroidManifest.xml with android:autoVerify="true", the VIEW action, the DEFAULT and BROWSABLE categories and the domain; and on the domain a /.well-known/assetlinks.json file containing the app's package name and the SHA-256 fingerprint of the signing certificate.
  • iOS Universal Links: the Associated Domains capability in Xcode with the domain prefixed by applinks:; and on the domain a /.well-known/apple-app-site-association file containing the app's team and bundle ID and which paths open in the app.

This setup was not run for this post: it needs a real domain, a signed build and a device; signing is the subject of the publishing post. We verified with the web build that the router handles the same URLs correctly. On the web, go_router uses the part of the URL after # by default; we opened two links in Chromium:

#/catalog/books/9780441172719 -> #/catalog/books/9780441172719, shows: Dune, console errors: 0
#/orders -> #/sign-in?from=/orders, shows: Sign in, console errors: 0

In Production

URLs are like an API contract. A link that goes into a notification, an email or a shared message is used for months. If you change a path, redirect the old one to the new one with a redirect; otherwise the user sees go_router's error page. Turn that error page into your own design with errorBuilder too.

Validate every parameter that arrives through a deep link. A book with no such ISBN, an order number that belongs to another user, or a redirect address such as from can come in. That is why the details page can show the "This book could not be found." state; for personal data such as orders, authorization is always checked on the server.

Test navigation. The router's behavior can be measured in a widget test without an emulator: reaching the router with GoRouter.of(context), calling go, push and pop, and reading routerDelegate.currentConfiguration.uri is enough. One thing to watch: on the fake clock of a widget test, if you await the Future returned by push directly, the test hangs, because the Future completes only on a later pump; our first attempt got stuck for exactly this reason. Start it with unawaited and write the result into a variable with then.

Common Pitfalls

The first pitfall is using go everywhere. If you open a screen that should return a result with go, the result never comes back and the back button goes somewhere unexpected.

The second pitfall is creating the GoRouter inside build. An ordinary rebuild, such as a theme change, resets all navigation.

The third pitfall is building an infinite loop in redirect. If you add /sign-in to the protected pages, redirect calls itself; go_router stops it with a "redirect loop detected" error, and the number of redirects in one navigation is limited to 5 by default.

The fourth pitfall is passing data that should come from the URL to screens as an object with extra. extra does not exist in a deep link and is lost on the web when the page is reloaded; the identity the page needs belongs in the path, and the data should be loaded from the repository.

A Small Experiment

★☆☆ Add a test line to authRedirect for the case where a signed-in user comes to /sign-in without from. What is the result?

★★☆ Add a ?format=ebook query parameter to the /catalog route: it should be read from state.uri.queryParameters and passed to CatalogViewModel as a filter. Verify in the web build that it opens with #/catalog?format=ebook.

★★★ What happens if a user who is not signed in taps "Checkout" on the cart screen? The pushed address page turns into the sign-in page through redirect; design the flow so that after signing in the user reaches the address form and returns to the cart with the result, and verify it with a router test.

In the next post we connect the app to real data: http and dio, JSON serialization, error handling with Result, parsing ProblemDetails, and loading a paged book list and creating an order against the ASP.NET Core Bookstore API.