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

* * *

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
    
*   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](https://pub.dev/documentation/go_router/latest/topics/Get%20started-topic.html) for understanding the concepts and 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 AppBar and Bottom Navigation Bar

Let's say you're building a typical multi-screen Flutter web app. You want a persistent `AppBar` at the top and a `BottomNavigationBar` at the bottom. When the user taps a tab, only the main content should update — **not** the AppBar or the BottomNavigationBar.

Sounds simple, right? But there's an important distinction between doing this with a plain `GoRoute` versus using `ShellRoute`.

### Without ShellRoute

If you stick to just `GoRoute` with separate `Scaffold`s 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** across every screen.
    
*   On the web, this feels clunky — like the whole page is reloading.
    
*   If you try to restore a tab via URL (e.g., going directly to `/search`), your layout structure is gone.
    

### With ShellRoute

`ShellRoute` solves this by wrapping all your routes in a **shared layout shell** that stays constant while only the inner content updates.

```dart
final GoRouter router = GoRouter(
  initialLocation: '/home',
  routes: [
    ShellRoute(
      builder: (context, state, child) {
        return ScaffoldWithNavBar(child: child);
      },
      routes: [
        GoRoute(
          path: '/home',
          builder: (context, state) => const HomeScreen(),
        ),
        GoRoute(
          path: '/search',
          builder: (context, state) => const SearchScreen(),
        ),
        GoRoute(
          path: '/settings',
          builder: (context, state) => const SettingsScreen(),
        ),
      ],
    ),
  ],
);
```

```dart
class ScaffoldWithNavBar extends StatelessWidget {
  final Widget child;
  const ScaffoldWithNavBar({required this.child, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('My App')),
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _calculateSelectedIndex(context),
        onTap: (index) => _onItemTapped(index, context),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
          BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'),
        ],
      ),
    );
  }

  int _calculateSelectedIndex(BuildContext context) {
    final location = GoRouterState.of(context).uri.toString();
    if (location.startsWith('/search')) return 1;
    if (location.startsWith('/settings')) return 2;
    return 0;
  }

  void _onItemTapped(int index, BuildContext context) {
    switch (index) {
      case 0: context.go('/home'); break;
      case 1: context.go('/search'); break;
      case 2: context.go('/settings'); break;
    }
  }
}
```

By doing this, you **preserve state**, **reduce redundant code**, and get **smooth, user-friendly navigation** — just like a native mobile app, with the URL awareness of the web.

## 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 page
    

There are two 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 role-specific access or route-specific preloading.
    

### Global Redirect

```dart
final GoRouter router = GoRouter(
  initialLocation: '/home',
  redirect: (context, state) {
    final authService = context.read<AuthService>();
    final isLoggedIn = authService.isAuthenticated;
    final isGoingToLogin = state.matchedLocation == '/login';

    if (!isLoggedIn && !isGoingToLogin) {
      return '/login';
    }
    if (isLoggedIn && isGoingToLogin) {
      return '/home';
    }
    return null; // no redirect needed
  },
  routes: [ /* ... */ ],
);
```

Before a user navigates to any screen, the global redirect runs first. It checks whether the token is expired or missing. If the token is invalid, the user is immediately redirected to the login page. If the user is already authenticated and tries to open login, they're sent to home. Otherwise, `null` is returned and navigation proceeds normally.

### Per-Route Redirect

```dart
GoRoute(
  path: '/dashboard',
  redirect: (context, state) {
    final user = context.read<AuthService>().currentUser;
    if (user?.profileComplete == false) {
      return '/complete-profile';
    }
    return null;
  },
  builder: (context, state) => const DashboardScreen(),
),
```

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.  After a successful login, they are automatically taken back to `/product/123`.
    

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

### Implementation

```dart
// In your AuthState / ChangeNotifier
class AppState extends ChangeNotifier {
  String? intendedPath;

  void setIntendedPath(String path) {
    intendedPath = path;
    notifyListeners();
  }

  void clearIntendedPath() {
    intendedPath = null;
    notifyListeners();
  }
}
```

```dart
// Global redirect — store intended path before redirecting to login
redirect: (context, state) {
  final authService = context.read<AuthService>();
  final appState = context.read<AppState>();
  final isLoggedIn = authService.isAuthenticated;
  final isGoingToLogin = state.matchedLocation == '/login';

  if (!isLoggedIn && !isGoingToLogin) {
    appState.setIntendedPath(state.uri.toString());
    return '/login';
  }
  return null;
},
```

```dart
// After successful login
void onLoginSuccess(BuildContext context) {
  final appState = context.read<AppState>();
  final target = appState.intendedPath ?? '/home';
  context.go(target);
  appState.clearIntendedPath();
}
```

This pattern is key for protected routes, deep linking, session-expired flows, and 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 go\_router in Flutter Web 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.

### Path Parameters

Path parameters encode resource identifiers directly into the route.

```dart
// Route definition
GoRoute(
  path: '/product/:id',
  builder: (context, state) {
    final productId = state.pathParameters['id']!;
    return ProductDetailScreen(productId: productId);
  },
),

// Navigating
context.go('/product/42');
// URL becomes: /product/42
```

### Query Parameters

Query parameters appear after `?` in the URL and handle optional values like search terms, filters, or sorting.

```dart
// Route definition
GoRoute(
  path: '/products',
  builder: (context, state) {
    final category = state.uri.queryParameters['category'];
    final page = state.uri.queryParameters['page'] ?? '1';
    return ProductListScreen(category: category, page: int.parse(page));
  },
),

// Navigating
context.go('/products?category=electronics&page=2');
```

### 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 state restoration on refresh, browser back/forward button functionality, and link sharing with complete context.

## Passing Complex Data Beyond Simple Strings

While URLs are great for encoding simple data, sometimes you need to pass **more complex data** between screens — full model objects, maps, UI-related state, or navigation context. That's where go\_router's `state.extra` comes in.

`state.extra` lets you attach **any Dart object** when navigating to a route. It won't show up in the URL, but it's accessible on the target screen.

### 1\. Passing a Custom Class Model

```dart
// Navigate with extra data
context.go('/product/detail', extra: product); // product is a ProductModel

// Receive it on the target route
GoRoute(
  path: '/product/detail',
  builder: (context, state) {
    final product = state.extra as ProductModel;
    return ProductDetailScreen(product: product);
  },
),
```

### 2\. Map / JSON-like Object

```dart
context.go('/checkout', extra: {
  'items': cartItems,
  'coupon': 'SAVE20',
});
```

### 3\. List of Items

```dart
context.go('/order-summary', extra: cartItems); // List<CartItem>
```

### 4\. Enum Values

```dart
enum SortOrder { priceAsc, priceDesc, newest }

context.go('/products', extra: SortOrder.newest);
```

### 5\. UI-Related State

```dart
context.go('/product/detail', extra: scrollController);
```

**Important caveats:**

*   `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 `pathParameters` or `queryParameters` 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`. This makes it difficult to land on the correct route inside a nested shell when using dynamic entry points.

**GitHub Reference:** [flutter/flutter#163876](https://github.com/flutter/flutter/issues/163876)

**Workaround:** Add a static dummy/redirect route before your parameterized route as a placeholder `defaultLocation`.

### 2\. Popping Nested Navigation Affects Parent Stack Unexpectedly

Using nested navigators (like with `StatefulShellRoute`), popping from a deeply nested screen can affect the entire shell.

**GitHub Reference:** [flutter/flutter#164969](https://github.com/flutter/flutter/issues/164969)

**Workaround:** Use `RouteNeglect` to isolate specific navigators so their back behavior doesn't interfere with other branches:

```dart
RouteNeglect(
  child: GoRouter(
    // nested navigator config
  ),
)
```

This tells go\_router not to 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. 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 it's small enough.

### 4\. redirect Doesn't Wait for Async Operations

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/splash screen** while the app initializes and resolves async auth state before the router is instantiated.

```dart
// Initialize auth before creating the router
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final authService = AuthService();
  await authService.init(); // async token resolution happens here
  runApp(MyApp(authService: authService));
}
```

### 5\. Default Behavior of .go() Wipes the Navigation Stack

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

**Fix:**

*   Use `.push()` to add to the stack without clearing it.
    
*   Use `.pushReplacement()` to replace the top of the stack.
    
*   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.

* * *

*Originally published on the* [*GeekyAnts Blog*](https://geekyants.com/blog/advanced-navigation-in-flutter-web-a-deep-dive-with-go-router)*. GeekyAnts is a global software development consultancy specializing in React Native, Flutter, and AI engineering.*
