Flutter GoRouter Redirects Silently Failing on Deep Links: Root Causes and Fixes

July 09, 2026 8 min read

You wire up a redirect in GoRouter, test it by tapping buttons inside the app, and everything behaves correctly. Then a real deep link arrives from a push notification or a marketing URL, and the redirect does nothing. The user lands on the wrong screen with zero error output.

This is one of the most frustrating Flutter routing bugs because GoRouter gives you no exception, no assertion, and no warning. The redirect function simply runs β€” or doesn't run at the right time β€” and the app quietly lands somewhere wrong.

What You'll Learn

  • Why GoRouter's redirect fires differently on a cold-start deep link versus in-app navigation
  • How async auth state causes the redirect to silently pass through
  • How to wire refreshListenable correctly so GoRouter re-evaluates redirects after state loads
  • How to detect redirect loops and null-return bugs before they reach production
  • A debug pattern that surfaces exactly what your redirect sees at the moment it runs

Prerequisites

This article assumes you are using the go_router package (version 6 or later), have basic Navigator 2.0 concepts under your belt, and have already set up deep link intent filters or universal links for your platform. If your routing structure is still growing, the article on structuring large Flutter applications for scalable growth gives a solid foundation before you layer in complex redirect logic.

How GoRouter Processes a Deep Link on Cold Start

When a user taps a deep link that launches your app from scratch, Flutter's platform channel delivers the initial URI before your widget tree is fully built. GoRouter intercepts that URI and attempts to match it against your route configuration. This sounds straightforward, but here's the catch: the redirect callback fires immediately during that match β€” before your asynchronous app initialization (auth check, token refresh, permission fetch) has completed.

During in-app navigation, the user is already past your splash or auth gate, so state is loaded and the redirect sees a clean picture. On a cold start, state is a blank slate. If your redirect makes a decision based on that blank state, it will always produce the wrong answer β€” silently.

The Redirect Callback: What It Sees and When

GoRouter's redirect is a synchronous function. It receives a GoRouterState argument and must return either a new path string (to redirect) or null (to allow navigation to proceed). It cannot await anything inside the callback itself.

redirect: (BuildContext context, GoRouterState state) {
  final isLoggedIn = ref.read(authProvider).isAuthenticated;
  if (!isLoggedIn) return '/login';
  return null;
},

If authProvider starts in a loading state (common with async initialization), isAuthenticated might be false even for a valid session that hasn't loaded yet. The redirect fires, sees false, sends the user to /login, and the deep link target is gone forever. No error, no stack trace.

Common Cause 1: Async State Not Ready When redirect Runs

This is the root cause in the majority of cases. Your auth or permission state is loaded asynchronously β€” from SharedPreferences, a token stored in secure storage, or a network call β€” and GoRouter's redirect runs before that future resolves.

The fix is to represent a three-state model in your auth notifier: loading, authenticated, and unauthenticated. Then in your redirect, treat loading as a special case that routes to a neutral splash screen, not to login.

enum AuthStatus { loading, authenticated, unauthenticated }

// In your redirect:
redirect: (context, state) {
  final authStatus = ref.read(authProvider).status;

  if (authStatus == AuthStatus.loading) {
    // Hold the user on a splash screen; GoRouter will re-run
    // redirect once refreshListenable fires.
    return '/splash';
  }

  if (authStatus == AuthStatus.unauthenticated) {
    return '/login';
  }

  // Authenticated β€” allow the original deep link to proceed.
  return null;
},

Critically, when auth finishes loading, you must tell GoRouter to re-evaluate the redirect. That is exactly what refreshListenable is for.

Common Cause 2: refreshListenable Not Wired Correctly

GoRouter only re-runs the redirect when its refreshListenable notifies listeners. If you omit it, or wire it to the wrong notifier, GoRouter never knows that state has changed and the user stays on /splash forever β€” or worse, is never redirected to the intended deep link after auth loads.

Here is the correct pattern with Riverpod and a ChangeNotifier-based bridge:

class AuthNotifier extends ChangeNotifier {
  AuthStatus _status = AuthStatus.loading;
  AuthStatus get status => _status;

  Future<void> initialize() async {
    _status = await _checkStoredSession()
        ? AuthStatus.authenticated
        : AuthStatus.unauthenticated;
    notifyListeners(); // GoRouter hears this and re-runs redirect
  }
}

final authNotifier = AuthNotifier()..initialize();

final router = GoRouter(
  refreshListenable: authNotifier,
  redirect: (context, state) {
    // ... same three-state logic as above
  },
  routes: [ ... ],
);

If you're using Riverpod without a ChangeNotifier, you can bridge a StateNotifier to GoRouter using GoRouterRefreshStream (available in recent go_router versions) by passing a stream from your provider:

refreshListenable: GoRouterRefreshStream(
  ref.read(authProvider.notifier).stream,
),

Without this bridge, your deep link redirect will only ever see the initial state snapshot. For related patterns around state not propagating when you expect it to, the article on Flutter Riverpod provider not updating UI after state change covers the same class of problem from a different angle.

Common Cause 3: redirect Returns null When It Should Return a Path

This one is subtle. Returning null from redirect means "let this navigation proceed as-is." If your route configuration has a mismatch β€” for example, the deep link path /product/42 is not declared in your routes β€” GoRouter will navigate to its errorBuilder page (or a blank screen if you have none). The redirect never fires for unrecognized paths in a meaningful way because there is nothing to redirect to.

Always verify that the deep link path exists in your route tree before debugging redirect logic:

GoRoute(
  path: '/product/:id',
  builder: (context, state) {
    final id = state.pathParameters['id']!;
    return ProductScreen(id: id);
  },
),

Also check that your redirect does not return null on a path that requires a guard. A common mistake is writing conditions that only cover the routes you were thinking about at the time, leaving new routes unguarded by default.

Common Cause 4: The Redirect Chain Is Too Long or Circular

GoRouter has a built-in redirect loop detection limit. If your redirect chain exceeds that limit (typically around five hops), GoRouter throws an assertion in debug mode β€” but in release mode the behavior can be silent depending on how the error is handled. A circular redirect looks like this:

  • /splash redirects to /login when unauthenticated
  • /login redirects to /splash because auth state is still loading
  • Loop begins

Fix circular redirects by making each guarded route responsible for only one concern. The splash route should never redirect back into the auth flow; it should be a neutral holding screen with no redirect of its own. Structure your redirect logic so that each route has a clear exit condition that does not point back at another redirect.

Debugging the Redirect Silently Failing

The fastest way to surface the problem is to add a debugPrint (or your logger of choice) at the very top of your redirect callback. Log the current location, the target location, and every piece of state the redirect checks:

redirect: (context, state) {
  final authStatus = ref.read(authProvider).status;
  debugPrint(
    '[GoRouter redirect] '
    'location=${state.matchedLocation} '
    'fullPath=${state.uri} '
    'authStatus=$authStatus'
  );

  if (authStatus == AuthStatus.loading) return '/splash';
  if (authStatus == AuthStatus.unauthenticated) return '/login';
  return null;
},

Run the app, trigger a deep link from the terminal using adb on Android or xcrun simctl openurl on iOS, and watch the console. You will immediately see whether the redirect fires at all, what state it observed, and what path it returned. If it fires once with loading and never fires again, your refreshListenable is not connected properly.

On Android, trigger a deep link with:

adb shell am start \
  -a android.intent.action.VIEW \
  -d "https://yourapp.com/product/42" \
  com.yourcompany.yourapp

On iOS Simulator:

xcrun simctl openurl booted "https://yourapp.com/product/42"

Compare the logs from a cold start (app not running) versus a warm start (app in background). The difference will pinpoint whether the issue is initialization timing or something in your running-state logic. This technique of logging what the failing function actually receives is the same discipline behind debugging Paramiko SFTP uploads that silently fail β€” the symptom is silence, so you have to make the internals speak.

Beyond logging, GoRouter's observers parameter accepts a list of NavigatorObserver instances. Wiring up a simple observer that prints didPush and didReplace events gives you a higher-level trace of what actually lands in the navigation stack:

class DebugNavObserver extends NavigatorObserver {
  @override
  void didPush(Route route, Route? previousRoute) {
    debugPrint('[Nav] pushed: ${route.settings.name}');
  }

  @override
  void didReplace({Route? newRoute, Route? oldRoute}) {
    debugPrint('[Nav] replaced: ${oldRoute?.settings.name} -> ${newRoute?.settings.name}');
  }
}

final router = GoRouter(
  observers: [DebugNavObserver()],
  // ...
);

Another subtle trap: if you create your GoRouter instance inside a widget's build method rather than as a top-level or provider-scoped object, GoRouter is re-created on every rebuild. Each new instance has a fresh state snapshot, which can cause the redirect to see stale loading state on every rebuild. This is the same category of bug as Flutter FutureBuilder rebuilding on every parent setState β€” object identity matters. Always create your router once and hold it at a stable scope.

Wrapping Up: Next Steps

Silent redirect failures on deep links almost always trace back to one of four things: async state that hasn't loaded yet, a missing or misconfigured refreshListenable, a redirect returning null where it should return a path, or a redirect chain that loops. Here's what to do right now:

  1. Audit your auth state model. If it has only two states (authenticated / unauthenticated), add a third loading state and handle it in your redirect.
  2. Verify your refreshListenable. Add a debugPrint inside notifyListeners or your stream emission to confirm GoRouter is being told when state changes.
  3. Add redirect logging in debug mode. Wrap it in a kDebugMode check so it strips out in release builds automatically.
  4. Test cold-start deep links explicitly. Force-quit the app, then trigger the link via adb or xcrun simctl. Never rely only on in-app navigation tests for redirect correctness.
  5. Move your GoRouter instance out of build. Scope it to a Riverpod provider or a top-level variable so it is created exactly once and holds stable state.

Getting this right pays off quickly. A reliable redirect layer means you can confidently add new protected routes, knowing the guard will actually run when users arrive from outside your app. If you're also seeing state-related UI bugs elsewhere in your Flutter app, the article on Flutter setState called after dispose covers another common lifecycle trap that often appears alongside routing issues.

Frequently Asked Questions

Why does my GoRouter redirect work in the app but not when opening a deep link from outside?

When a deep link opens the app cold, GoRouter fires the redirect before your async initialization (auth check, token load) completes. Inside the app, state is already loaded, so the redirect sees the correct values. You need a three-state auth model and a properly wired refreshListenable to handle this timing gap.

How do I stop GoRouter from redirecting to login when a deep link arrives for an authenticated user?

Add a loading state to your auth model and return a neutral splash path from the redirect while auth is still initializing. Once your AuthNotifier calls notifyListeners after the session is confirmed, GoRouter re-runs the redirect and sees the authenticated state, then allows the original deep link to proceed.

What does refreshListenable do in GoRouter and why does it matter for deep links?

refreshListenable tells GoRouter which Listenable to watch for changes. When that object notifies its listeners, GoRouter re-evaluates the redirect callback. Without it, GoRouter only runs the redirect once on the initial navigation and never rechecks after your async state finishes loading.

How can I tell if my GoRouter redirect is actually running when a deep link arrives?

Add a debugPrint at the top of your redirect callback that logs the current URI and every state value it checks, then trigger the deep link via adb on Android or xcrun simctl openurl on iOS. If nothing prints, the redirect is not firing; if it prints once and stops, your refreshListenable is not connected.

Can a redirect loop cause GoRouter to silently fail instead of throwing an error?

In debug mode, GoRouter throws an assertion when the redirect chain exceeds its limit. In release mode the behavior can be silent, leaving users stuck on an unexpected screen. Prevent loops by ensuring each route in the chain has a clear terminal condition that does not point back at another redirecting route.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.