Skip to main content

Command Palette

Search for a command to run...

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.

Published
10 min read
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 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 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 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.

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(),
        ),
      ],
    ),
  ],
);
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

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

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

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

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

  void clearIntendedPath() {
    intendedPath = null;
    notifyListeners();
  }
}
// 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;
},
// 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.

// 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.

// 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

// 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

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

3. List of Items

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

4. Enum Values

enum SortOrder { priceAsc, priceDesc, newest }

context.go('/products', extra: SortOrder.newest);
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

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

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

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.

// 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. GeekyAnts is a global software development consultancy specializing in React Native, Flutter, and AI engineering.

6 views