Navigation & Routing

Route Guards and Redirect in GoRouter

16 min Lesson 12 of 14

Route Guards and Redirect in GoRouter

Authentication-based route protection is one of the most critical requirements in any real-world Flutter application. GoRouter addresses this elegantly through its redirect callback and the refreshListenable parameter. Together, they allow you to intercept every navigation attempt, evaluate whether the user is authorised to proceed, and automatically redirect them to a login screen if they are not — all reactively, without writing boilerplate navigation code in every screen.

Note: Route guards in GoRouter are declarative. Rather than sprinkling auth checks inside individual screens, you centralise the logic in one place: the redirect callback on your GoRouter instance. This keeps your screens clean and makes the protection easy to audit.

How the redirect Callback Works

The redirect callback is an optional function you supply to GoRouter. It is invoked before any navigation is completed. It receives a BuildContext and a GoRouterState describing the intended destination. You can examine the state, decide whether to allow the navigation, and return either null (allow) or a new path string (redirect). The redirect fires for every navigation event, including the initial deep link.

Basic redirect Callback

GoRouter(
  initialLocation: '/home',
  redirect: (BuildContext context, GoRouterState state) {
    final bool isLoggedIn = AuthService.instance.isAuthenticated;
    final bool isGoingToLogin = state.matchedLocation == '/login';

    // Not logged in and not already heading to login? Redirect.
    if (!isLoggedIn && !isGoingToLogin) return '/login';

    // Logged in but heading to login? Send home instead.
    if (isLoggedIn && isGoingToLogin) return '/home';

    // Allow navigation as requested.
    return null;
  },
  routes: [
    GoRoute(path: '/login',  builder: (ctx, state) => const LoginScreen()),
    GoRoute(path: '/home',   builder: (ctx, state) => const HomeScreen()),
    GoRoute(path: '/profile',builder: (ctx, state) => const ProfileScreen()),
  ],
)

Making the Router React to Auth Changes with refreshListenable

A one-time check at startup is not enough. When the user logs in or logs out, the router must re-evaluate the redirect callback and navigate accordingly. This is where refreshListenable comes in. You pass any Listenable — typically a ChangeNotifier — to this parameter. Whenever the listenable notifies its listeners, GoRouter calls redirect again for the current location. This creates a fully reactive auth flow.

AuthNotifier + GoRouter with refreshListenable

/// A ChangeNotifier that exposes the current auth status.
class AuthNotifier extends ChangeNotifier {
  bool _isLoggedIn = false;

  bool get isLoggedIn => _isLoggedIn;

  Future<void> logIn(String email, String password) async {
    // Perform your real auth call here.
    await Future.delayed(const Duration(seconds: 1));
    _isLoggedIn = true;
    notifyListeners(); // <-- triggers GoRouter to re-run redirect
  }

  Future<void> logOut() async {
    _isLoggedIn = false;
    notifyListeners(); // <-- triggers GoRouter to re-run redirect
  }
}

// In your app setup:
final AuthNotifier _authNotifier = AuthNotifier();

final GoRouter router = GoRouter(
  initialLocation: '/home',
  refreshListenable: _authNotifier, // re-evaluates redirect on auth change
  redirect: (BuildContext context, GoRouterState state) {
    final bool loggedIn = _authNotifier.isLoggedIn;
    final String location = state.matchedLocation;

    final bool onLogin = location == '/login';
    final bool onSplash = location == '/';

    if (!loggedIn && !onLogin && !onSplash) {
      // Save intended destination as a query param for post-login redirect.
      return '/login?from=${Uri.encodeComponent(location)}';
    }
    if (loggedIn && onLogin) return '/home';

    return null;
  },
  routes: [
    GoRoute(path: '/',       builder: (ctx, state) => const SplashScreen()),
    GoRoute(path: '/login',  builder: (ctx, state) => const LoginScreen()),
    GoRoute(path: '/home',   builder: (ctx, state) => const HomeScreen()),
    GoRoute(
      path: '/settings',
      builder: (ctx, state) => const SettingsScreen(),
    ),
    GoRoute(
      path: '/admin',
      builder: (ctx, state) => const AdminDashboard(),
      redirect: (ctx, state) {
        // Nested per-route guard: require admin role.
        if (!_authNotifier.isLoggedIn) return '/login';
        if (!_authNotifier.isAdmin) return '/home';
        return null;
      },
    ),
  ],
);

Per-Route Redirect vs Top-Level Redirect

GoRouter supports redirect callbacks at two levels:

  • Top-level redirect — defined on the GoRouter instance. Runs for every navigation event across the entire app. Ideal for global auth checks.
  • Per-route redirect — defined directly on a GoRoute. Runs only when that specific route (or a route inside a nested ShellRoute) is matched. Ideal for fine-grained role-based access (e.g., admin-only pages).

Both levels execute on every navigation. If the top-level redirect returns a new path, GoRouter redirects there without evaluating per-route redirects for the original destination. Both levels can coexist and complement each other.

Redirecting Back After Login

A polished auth flow remembers where the user was going before they were redirected to login, and takes them there after a successful authentication. The pattern is to encode the intended destination as a query parameter (commonly called from) when redirecting to /login. The LoginScreen reads it back and calls context.go(from) after successful login, or falls back to /home if the parameter is absent.

Tip: Always Uri.encodeComponent the from path before appending it as a query value. Paths that contain slashes or query strings of their own will otherwise break the parent URL.

Async Auth State with StreamProvider

When your auth status comes from a Stream (e.g., Firebase Auth), you can wrap the stream in a StreamProvider or a custom ChangeNotifier that listens to the stream and calls notifyListeners() on each event. Pass that notifier as refreshListenable and read the current value inside redirect. This ensures GoRouter reacts to sign-in and sign-out events from any source — local, OAuth, or biometric.

Warning: Never perform async work (network calls, database reads) directly inside the redirect callback. The callback must be synchronous. Instead, maintain auth state in a ChangeNotifier that updates asynchronously in the background and notifies GoRouter when ready. Reading a cached, in-memory value inside redirect is always safe and fast.

Summary

GoRouter's redirect callback is your single, centralised place to enforce authentication and authorisation rules across every route in your Flutter app. By pairing it with a ChangeNotifier passed to refreshListenable, the router reacts automatically whenever the user's auth status changes — logging them in routes them home; logging them out routes them to login. Per-route redirects let you add fine-grained role checks on top of the global guard. Always keep the callback synchronous and rely on a cached in-memory state object for the current auth status.