Skip to main content

Solving Infinite Redirection Loops in Flutter GoRouter Auth Flows

 Implementing protected routes in Flutter should be straightforward, yet it remains one of the most common sources of frustration when adopting go_router. You define a redirect guard, wire it up to your authentication state, run the app, and immediately hit a wall: the console explodes with a "Stack Overflow" error, or the UI stutters as the router bounces endlessly between / and /login.

This isn't a bug in GoRouter. It is a logical recursion error in how the redirection state is handled.

The Root Cause: Recursive Redirection

To fix the loop, you must understand the mechanics of the redirect callback.

GoRouter's redirect function triggers on every navigation event. This includes the navigation events initiated by the redirect function itself.

Here is the anatomy of an infinite loop:

  1. User starts app (unauthenticated).
  2. Router attempts to load / (Home).
  3. Guard checks: isLoggedIn is false.
  4. Guard returns: /login.
  5. Router navigates to /login.
  6. Guard runs again (because navigation happened).
  7. Guard checks: isLoggedIn is false.
  8. Guard returns: /login.
  9. Repeat step 5 forever.

The missing piece in most implementations is a check to see if the user is already at the destination. You must instruct GoRouter to return null (no redirection) if the user is already where they are supposed to be.

The Architecture

For a robust, production-grade auth flow, we need three components:

  1. AuthController: A Listenable (ChangeNotifier) that holds the source of truth.
  2. Top-Level Redirection Logic: A centralized guard function.
  3. Router Configuration: Wiring the refreshListenable to the controller.

The Solution

Below is a complete, copy-pasteable implementation using Flutter 3.x and go_router 13+.

1. The Auth Controller

This controller manages the authenticated state and notifies listeners when it changes. This is critical for triggering the router to re-evaluate the redirect logic.

import 'package:flutter/material.dart';

class AuthController extends ChangeNotifier {
  bool _isAuthenticated = false;

  bool get isAuthenticated => _isAuthenticated;

  // Simulate a login process
  Future<void> login(String username, String password) async {
    // In a real app, you would call an API here
    await Future.delayed(const Duration(milliseconds: 500));
    _isAuthenticated = true;
    notifyListeners();
  }

  // Simulate a logout process
  Future<void> logout() async {
    await Future.delayed(const Duration(milliseconds: 500));
    _isAuthenticated = false;
    notifyListeners();
  }
}

2. The Router Configuration

This is where the magic happens. We inject the AuthController into the router configuration.

Pay close attention to the redirect logic block.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'auth_controller.dart'; // Import your controller

// Define your routes as constants to avoid typos
class AppRoutes {
  static const home = '/';
  static const login = '/login';
  static const profile = '/profile';
}

class AppRouter {
  final AuthController authController;

  AppRouter(this.authController);

  late final GoRouter router = GoRouter(
    initialLocation: AppRoutes.home,
    debugLogDiagnostics: true, // Useful for debugging router events
    
    // CRITICAL: This ensures the router re-evaluates 'redirect' 
    // whenever the auth state changes.
    refreshListenable: authController, 
    
    routes: [
      GoRoute(
        path: AppRoutes.home,
        builder: (context, state) => const HomeScreen(),
      ),
      GoRoute(
        path: AppRoutes.login,
        builder: (context, state) => const LoginScreen(),
      ),
    ],

    // The Guard Logic
    redirect: (BuildContext context, GoRouterState state) {
      // 1. Check current auth status
      final bool loggedIn = authController.isAuthenticated;

      // 2. Check where the user is currently going
      // We use state.uri.path because it handles query params gracefully
      final bool isLoggingIn = state.uri.path == AppRoutes.login;

      // 3. Scenario: User is NOT logged in
      if (!loggedIn) {
        // If they are not logged in and not heading to login, redirect to login.
        if (!isLoggingIn) {
          return AppRoutes.login;
        }
        // IF they are already heading to login, return null.
        // This prevents the infinite loop: /login -> /login
        return null;
      }

      // 4. Scenario: User IS logged in
      if (loggedIn) {
        // If they are logged in but still on the login page, send them home.
        if (isLoggingIn) {
          return AppRoutes.home;
        }
      }

      // 5. No redirection needed
      return null;
    },
  );
}

3. Wiring it up in Main

Ensure your AuthController is instantiated early and passed effectively.

void main() {
  final authController = AuthController();
  final appRouter = AppRouter(authController);

  runApp(MyApp(router: appRouter.router, authController: authController));
}

class MyApp extends StatelessWidget {
  final GoRouter router;
  final AuthController authController;

  const MyApp({
    super.key,
    required this.router,
    required this.authController,
  });

  @override
  Widget build(BuildContext context) {
    // If using Provider, you might provide the AuthController here
    // For this example, we pass it directly to screens via constructor 
    // or scoping to keep the example dependency-free.
    return MaterialApp.router(
      routerConfig: router,
      title: 'GoRouter Auth Guard',
    );
  }
}

Why This Works

The solution relies on four distinct checks that act as gatekeepers to prevent recursion.

  1. refreshListenable: By passing the AuthController here, GoRouter automatically re-runs the redirect method whenever notifyListeners() is called. Without this, your app won't react when the user hits "Login".
  2. isLoggingIn Check: This is the loop killer. We explicitly calculate state.uri.path == '/login'.
  3. !loggedIn && !isLoggingIn: This handles the "Unauthenticated Access" attempt. We only redirect to login if they aren't already there.
  4. return null: This is arguably the most important line. Returning null tells GoRouter: "The current navigation intent is valid. Proceed."

Conclusion

Infinite loops in GoRouter are almost always caused by failing to check the state.matchedLocation or state.uri.path against the redirection target. By explicitly verifying if the user is already at the destination (/login), you break the recursion chain. Combine this with refreshListenable, and you have a reactive, secure authentication flow.