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:
- User starts app (unauthenticated).
- Router attempts to load
/(Home). - Guard checks:
isLoggedInis false. - Guard returns:
/login. - Router navigates to
/login. - Guard runs again (because navigation happened).
- Guard checks:
isLoggedInis false. - Guard returns:
/login. - 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:
- AuthController: A
Listenable(ChangeNotifier) that holds the source of truth. - Top-Level Redirection Logic: A centralized guard function.
- Router Configuration: Wiring the
refreshListenableto 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.
refreshListenable: By passing theAuthControllerhere, GoRouter automatically re-runs theredirectmethod whenevernotifyListeners()is called. Without this, your app won't react when the user hits "Login".isLoggingInCheck: This is the loop killer. We explicitly calculatestate.uri.path == '/login'.!loggedIn && !isLoggingIn: This handles the "Unauthenticated Access" attempt. We only redirect to login if they aren't already there.return null: This is arguably the most important line. Returningnulltells 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.