Table of Contents

Advanced Navigation in Flutter Web: A Deep Dive with Go Router

Master advanced navigation in Flutter Web using go router. Learn deep linking, auth redirects, ShellRoute layouts, and more to scale your app like a pro.

Author

Prince Kumar Thakur
Prince Kumar ThakurTechnical Content Writer

Subject Matter Expert

Uttam Kini H
Uttam Kini HSoftware Engineer - I

Date

Jun 16, 2025
Advanced Navigation in Flutter Web: A Deep Dive with Go Router

Book a Discovery Call

When building multi-screen apps, especially for the web, managing navigation in Flutter can quickly become complex. From keeping your app's UI in sync with the browser's URL bar to managing deep links, authentication flows, and dynamic layouts, traditional Navigator and Route handling often fall short in providing a clean, scalable solution.

That’s where go_router steps in.

Developed by the Flutter team itself, go_router is now an official package endorsed and maintained by the Flutter team. It addresses common navigation challenges by providing:

  • Declarative route definitions,
  • Built-in support for redirection (ideal for authentication),
  • Seamless deep linking,
  • URL synchronization,
  • and platform-agnostic design (supporting mobile, web, and desktop).

As Flutter apps scale up with more screens, complex states, and varying navigation patterns (like tabs and drawers), go_router helps you write cleaner and more predictable routing logic. It was designed to offer the Flutter-style declarative approach with the web-style navigation feel, making it an essential tool for modern Flutter development.

To get started with go_router, refer to the official documentation for understanding the concepts and learning the basics of Configuration and Navigation.

In this blog, we go beyond the basics — diving into how go_router can be harnessed to build sophisticated navigation setups with app bars, bottom nav bars, deep links, and more — all while keeping your code manageable and your routes meaningful.

How to handle Navigation with App bar and Bottom Navigation bar

Let’s say you’re building a typical multi-screen Flutter web app. You want to create a persistent AppBar at the top and a BottomNavigationBar at the bottom — or possibly just one of them. When the user taps a tab, only the main content should update — not the AppBar or the BottomNavigationBar.

Sounds simple, right?

Let’s look at two videos — one using basic go_router and the other using the ShellRoute class in go_router.

Sounds simple, right?

Let us check out these two videos, one with simple go_router and one with go_router ShellRoute class.

1. Without ShellRoute (just by using go_router)

There are three screens — Home, Settings, and Search. Each of these screens has its own AppBar and BottomNavigationBar.

without_shell_route.mov

2. With ShellRoute
In this version, we use a single skeleton class (the shell) that holds the shared AppBar and BottomNavigationBar. All the sub-screens are rendered inside this skeleton layout as children of the ShellRoute.

with_shell_route.mov

Here’s what happens if you stick to just GoRoute with separate Scaffolds in each screen:

  • Each time you switch screens, Flutter rebuilds the entire page, including the AppBar and BottomNavigationBar.
  • Your layout flashes or resets unnecessarily.
  • You duplicate UI code (same Scaffold, same AppBar) across every screen.
  • On the web, this feels clunky — like the whole page is reloading.
  • And if you try to restore a tab via URL (e.g., going directly to /search), your layout structure is gone.

That’s when you realize: you need a shared layout — one that wraps all your routes and stays constant, while only the inner content updates.

If you look at the go_router code using ShellRoute, it follows this structure:

  • A parent shell defines the shared layout (AppBar, BottomNavigationBar).
  • Inside this ShellRoute, we define the child routes.
  • When a user taps a tab, only the child content changes — the shared layout remains intact.

By doing this:

  • You preserve state,
  • You reduce redundant code,

You get smooth, user-friendly navigation — just like a native mobile app.

Managing Authentication flows using redirect methods.

Handling authentication in navigation is a common use case — whether it’s protecting certain routes or redirecting users based on login state.

The redirect method in go_router helps you define navigation logic before a screen is shown. You can use it to:

  • Redirect users if their token is missing or expired
  • Prevent access to protected pages when not logged in
  • Automatically send logged-in users to the home screen if they open the login screen

There are 2 levels of redirect in go_router

  1. Global Redirect (at the GoRouter level):
    Best for app-wide decisions like login state.
  2. Per-route Redirect (at the GoRoute level):
    Useful for more granular rules, like checking role-specific access or route-specific preloading.

Now let us look at some code:

Before a user navigates to any screen, the global redirect method runs first. It checks whether the token is expired or missing. If the token is invalid during any navigation attempt, the user is immediately redirected to the login page to re-authenticate.

If the user is already authenticated (i.e., the token is valid), the redirect returns null, allowing go_router to proceed with the intended navigation.

Now that we've used a global redirect, let’s talk about per-route redirects in GoRoute.

The redirect method defined inside a GoRoute can be used for more specific, route-level checks before the builder method is called. For example, you might want to check if a user’s profile is incomplete and redirect them to /complete-profile before showing the actual screen.

Here’s the order in which go_router evaluates navigation logic:

  1. Global redirect in the GoRouter class
  2. Per-route redirect in the individual GoRoute
  3. builder method of the intended route

Supporting Intended URLs (Preserving the Target Route Before Login)

In large-scale apps like Amazon or Flipkart, if a user tries to access a protected route (e.g. /product/123) without being logged in:

  1. They are redirected to the login screen.
  2. But after a successful login, they’re automatically taken back to /product/123.

This flow creates a seamless and intuitive experience — the app "remembers" where the user wanted to go.

In Flutter with go_router, you can implement this using:

  • The redirect method (global or per-route)
  • A shared state (e.g., using Provider, Riverpod, or any other state management solution) to store the intended URL

Let us understand the flow:

  1. In the global redirect, check if the user is unauthenticated.
  2. If they are, store state.uri.toString() (which holds the full requested path).
  3. Redirect them to the login page.
  4. After successful login, check if there was a stored path.
  5. Navigate back to the stored URL

In the global redirect:

After successful login:

final target = appState.intendedPath ?? '/home';

context.go(target); // redirect to originally intended path

appState.clearIntendedPath();

You can use:

  • A ChangeNotifier, StateNotifier, or Riverpod provider
  • Or even a simple singleton service

Just ensure that it survives the login screen and is cleared after use.

This pattern is key for:

  • Protected routes
  • Deep linking
  • Session-expired flows
  • Checkout or payment pages

It provides a professional UX where the app never forgets where the user was going.

Taking Advantage of URLs in Flutter Web with go_router

One of the biggest advantages of using go_router in a Flutter Web application is its deep integration with browser URLs. Unlike mobile apps, URLs in web apps are visible, shareable, and reloadable — so your navigation should reflect meaningful, structured paths.

With go_router, we can use:

  • Path parameters to encode resource identifiers
  • Query parameters to manage filters, search, and UI state
  • Clean and structured routes that make the app easier to understand and debug

Path Parameters

Path parameters are parts of the URL that represent a variable segment. These are useful when you want to encode IDs or slugs directly into the route.

Example URL:

Example route:

This pattern makes URLs more readable and allows direct navigation to specific resources.

Query Parameters

Query parameters appear after a ? in the URL and are commonly used for optional parameters like search terms, filters, or sorting.

Example URL:

Example route:

This makes your app’s state reflective in the URL, which improves shareability and supports refresh and bookmarking.

Syncing UI State with URLs

With go_router, your URL can act as a single source of truth. You can store things like:

  • The selected tab: /dashboard?tab=analytics
  • The current page: /products?page=2
  • The active filter: /products?category=electronics&inStock=true

This enables:

  • Restoring state on refresh
  • Browser back/forward button functionality

Link sharing with complete context

Passing Complex Data Beyond Simple Strings in go_router

While URLs are great for encoding simple data like IDs and query parameters, sometimes you need to pass more complex data between screens — things like:

  • Full model objects
  • Maps or JSON-like structures
  • UI-related state (like a ScrollController)
  • Navigation context (e.g., came from search page)

Passing these via URL becomes impractical. That’s where go_router's state.extra comes in.

state.extra allows you to attach any Dart object when navigating to a route. Think of it as a temporary payload you send along with the navigation — it won’t show up in the URL, but it's accessible on the target screen.

Use Cases:

1. Passing a Custom Class Model Let’s say you have a ProductModel class and want to send the whole object to a detail screen:

2. Map<String, dynamic> (e.g., JSON-like object)

Useful when passing form data or filters.

3. List of items

For example, cart items:

4. Enum values

Say you have a sorting enum:

5. UI-related state (advanced use)

Rare, but possible if you're controlling some behavior:

Some of the important things you need to remember while using state.extra

  • state.extra is not persisted — if the user refreshes or shares the URL, the data is lost.
  • Don’t use it for critical state that must survive a browser reload.

Combine it with state.pathParameters or query parameters if you need both persisted and transient data.

Identifying and Resolving Potential Issues with go_router

1. StatefulShellBranch does not support parameterized default locations

You can’t use a path like /product/:id as a defaultLocation inside a StatefulShellBranch.

When you're building apps with dynamic entry points (like going straight to /product/123), this limitation makes it difficult to land on the correct route inside a nested shell.

GitHub Reference:

flutter/flutter#163876

Current workaround:

One workaround could be to add a dummy route before your parameterized route as suggested by stackoverflow

2. Popping nested navigation affects parent stack unexpectedly

Using nested navigators, like with StatefulShellRoute, popping from a deeply nested screen can cause the entire shell to be affected.

Workaround:

Use RouteNeglect to isolate specific navigators so that their back behavior doesn’t interfere with other branches.

GitHub Reference:

👉 flutter/flutter#164969

Example:

Wrap the nested screen with RouteNeglect like this:

This tells go_router to not consider this route when deciding if the shell branch should pop.

3. state.extra is lost on browser refresh

When passing complex data using state.extra, that data is not persisted in the URL. So refreshing the browser window will lose the data.

Workaround:

  • Store critical values in the URL or use local storage/state management to persist.
  • Use queryParameters or embed the data in pathParameters if small enough.

4. redirect doesn't wait for async operations (e.g. fetching from shared preferences)

The redirect method in go_router must be synchronous, but auth logic often depends on async checks (like reading tokens from secure storage).

Workaround:

  • Use a loading screen while the app is initializing.

5. Default behavior of .go() wipes navigation stack

If you're coming from a native/mobile mindset, .go() behaves more like a replace, which can feel unexpected.

Fix:

  • Use .push() or .pushReplacement() as needed.
  • Understand that .go() resets the stack, which is useful for deep links but not always desirable during internal navigation.

Navigating multi-screen Flutter web apps with go_router goes far beyond just pushing and popping routes. From managing authentication flows and preserving intended URLs, to leveraging state.extra for complex data and identifying subtle bugs through real examples — go_router gives you the flexibility and control you need. Whether you're building a large-scale app or a focused product experience, mastering go_router will help you build intuitive, robust navigation flows on the web.

Related Articles

Dive deep into our research and insights. In our articles and blogs, we explore topics on design, how it relates to development, and impact of various trends to businesses.