With go_router, how do you redirect signed-out users of a banking app to a login route and send them back after sign-in?
answer
- one callback on the GoRouter itself
- return a location, or null to stay
- check matchedLocation before redirecting
- refreshListenable re-runs it on sign-in
- carry the origin in a from query
basics
~20 sGive GoRouter a top-level redirect that returns '/login' for a signed-out user and null otherwise, and pass the session ChangeNotifier as refreshListenable so the redirect runs again the moment the user signs in or out.
solid answer
~40 sgo_router's top-level `redirect` has the signature `FutureOr<String?> Function(BuildContext, GoRouterState)` and runs on every navigation before any `GoRoute.redirect`. For the banking app it returns a login location when the session is signed out, and `null` (or the current location) to let the navigation through. I check `state.matchedLocation == '/login'` so the login page itself is exempt, encode the original location as `?from=` with the `Uri` class, and after sign-in send the user from `/login` back to `from`. The key extra is `refreshListenable: session`: a `Listenable` such as a `ChangeNotifier` that makes go_router re-parse the current location, so signing in or out re-runs the redirect without any screen calling `go`. The router is created once, outside `build`.
code
dart · 51 linesimport 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class BankSession extends ChangeNotifier {
bool _signedIn = false;
bool get signedIn => _signedIn;
void signIn() {
_signedIn = true;
notifyListeners();
}
void signOut() {
_signedIn = false;
notifyListeners();
}
}
final BankSession session = BankSession();
final GoRouter router = GoRouter(
initialLocation: '/accounts',
refreshListenable: session,
redirect: (BuildContext context, GoRouterState state) {
final bool atLogin = state.matchedLocation == '/login';
if (!session.signedIn) {
if (atLogin) return null;
return Uri(path: '/login', queryParameters: <String, String>{'from': state.uri.toString()})
.toString();
}
if (atLogin) {
final String? from = state.uri.queryParameters['from'];
return (from != null && from.startsWith('/')) ? from : '/accounts';
}
return null;
},
routes: <RouteBase>[
GoRoute(
path: '/login',
builder: (BuildContext context, GoRouterState state) =>
TextButton(onPressed: session.signIn, child: const Text('Sign in')),
),
GoRoute(
path: '/accounts',
builder: (BuildContext context, GoRouterState state) =>
TextButton(onPressed: session.signOut, child: const Text('Sign out')),
),
],
);
void main() => runApp(MaterialApp.router(routerConfig: router));go deeper
Recall that a top-level redirect returns a location or null, and that refreshListenable makes go_router run it again when the session notifies.
Explain the three branches of an auth redirect, why it checks matchedLocation, how the from parameter round-trips, and that top-level redirects run before route-level ones.
Show you guard deep links and token expiry in the router rather than in screens, keep the redirect cheap, and validate the from parameter before following it.
Discuss where access rules should live in a large app: one central redirect versus per-feature GoRoute redirects, and how to keep them from contradicting each other.
## Where the auth guard lives In go_router, access rules belong in the **router**, not in screens. A screen that checks the session in `initState` and calls `context.go('/login')` flashes protected content, misses deep links that land on another screen, and duplicates the rule everywhere. The router offers two redirect hooks: - **Top-level `redirect`** on the `GoRouter` constructor, called for every navigation before routes are processed. This is the natural home of a signed-in check. - **Route-level `GoRoute.redirect`**, called when a navigation is about to display that route, useful for rules that concern one subtree. Both have the type `GoRouterRedirect`, which is `FutureOr<String?> Function(BuildContext context, GoRouterState state)`. Returning a string moves the navigation to that location; returning `null`, or the location already being shown, leaves it alone. ## The banking-app redirect The rule has three branches: 1. **Signed out and not on `/login`**: send the user to `/login`, remembering where they were going in a `from` query parameter. 2. **Signed in and on `/login`**: the login page is no longer useful, so return `from` if present, otherwise `/accounts`. 3. **Anything else**: return `null`. Two details prevent classic bugs. First, the check uses `state.matchedLocation` (the matched path, without the query string), so `/login?from=...` still counts as the login page. Second, the `from` value is built with `Uri(path: '/login', queryParameters: {'from': state.uri.toString()})`, which encodes it; reading it back through `state.uri.queryParameters['from']` decodes it. ## refreshListenable: re-running the rule when the session changes A redirect runs when a navigation happens. Signing in is not a navigation: the user is still on `/login` when the session flips. `refreshListenable` closes that gap. It accepts any `Listenable`; go_router subscribes to it, and every notification makes the router re-parse the current location, which runs `redirect` again: - The session is a `ChangeNotifier` whose `signIn()` and `signOut()` call `notifyListeners()`. - After sign-in, the redirect sees a signed-in user on `/login` and returns `from`. - After sign-out, or when a token expires and the session notifies, a user deep inside `/accounts/42` is sent to `/login?from=/accounts/42` with no screen involved. A redirect that reads an `InheritedWidget` through `dependOnInheritedWidgetOfExactType` (how most `of(context)` lookups work) is also re-evaluated when that widget changes. A `Stream`-based auth source needs wrapping in a `Listenable`; go_router 5 removed its own stream adapter. ## What the user experiences A signed-out user taps a shared link to `/accounts/42` on a cold start: 1. The platform hands go_router the location `/accounts/42`; the redirect sees a signed-out session and returns a `/login` location whose `from` query parameter holds `/accounts/42`. 2. The redirect runs again on the new location; `matchedLocation` is `/login`, so it returns `null` and the login page is shown. 3. The user signs in; the session calls `notifyListeners()`. 4. go_router re-parses the current login location; the redirect now sees a signed-in user on `/login` and returns `/accounts/42`. 5. The account page appears, with the accounts list beneath it if the route is nested, exactly as if the user had navigated there. No screen contains navigation code for any of this; the login page only calls `signIn()`. ## Ordering and limits | Hook | When it runs | Typical use | |---|---|---| | `onEnter` (since go_router 16.3.0) | first, once per navigation, against the literal target | block a navigation outright | | top-level `redirect` | next, and again on each new location it produces | session guard | | `GoRoute.redirect` | for the matched routes | per-feature rules | Every location produced by redirects in one navigation is recorded. Producing the same location twice fails with a redirect-loop `GoException`, and more hops than `redirectLimit` (default **5**) fail with a too-many-redirects one; both end on the error screen. ## Practical rules - Create the `GoRouter` **once** (a top-level final, a field of a long-lived object, or a provider) and hand it to `MaterialApp.router(routerConfig: router)`. Building it inside `build` resets navigation to the initial location whenever that widget rebuilds. - Keep the redirect fast and side-effect free; it may run many times. It may be `async`, and go_router waits for the returned `Future`, but a slow check delays every navigation. - Treat `from` as untrusted input: accept it only when it starts with `/`, so a crafted link cannot send the user somewhere unexpected after sign-in.
- With go_router, why must the GoRouter be created once instead of inside a build method?A new `GoRouter` is a new routing configuration and a new route information provider. Rebuilding the widget that creates it resets navigation to the initial location and throws away the current stack, so the router lives in a top-level final, a long-lived object, or a provider.
- With go_router, in what order do the top-level redirect and GoRoute.redirect run?The top-level redirect runs first; if it produces a new location, it runs again on that location. Then route-level redirects run for the matched routes. All hops share one redirect history, so loops and the redirectLimit are enforced across both kinds.
- With go_router, can a redirect wait for an asynchronous token check?Yes. `GoRouterRedirect` returns `FutureOr<String?>`, so the callback can be `async` and go_router waits for it. Because it runs on every navigation, keep it quick, for example by reading an already-loaded session rather than calling the network each time.
The redirect is a doorman who checks every visitor at the lobby; refreshListenable is the radio that tells him a resident's badge was just revoked, so he walks upstairs and escorts that resident out without waiting for them to use a door.
saying these in an interview costs you the question
- Calls context.go('/login') from each protected screen's initState instead of a redirect.
- Omits refreshListenable, so signing out leaves the user on the accounts page.
- Believes the redirect runs only once, when the app starts.
- Creates the GoRouter inside build, resetting navigation on every rebuild.
- Believes refreshListenable only accepts a Stream of auth events.