You built a Flutter app. It navigates. Then a user taps a notification, the app opens to the wrong screen. Someone sends a product link, the app just opens the home page. Your back button pops two screens instead of one. You add a bottom navigation bar and suddenly each tab forgets where it was.
All of these are the same problem: Navigator.push() has no concept of URLs. Every screen is a disposable widget on a stack with no address, no identity, and no way for the outside world to reach it. The moment your app gets even slightly complex — deep links, auth flows, web URLs, persistent tabs — it falls apart.
GoRouter fixes all of this. It gives every screen in your Flutter app a real URL path. Navigation becomes declaring where you want to go, not manually stacking and popping widgets. Deep links just work. Auth guards live in one place. The back button behaves. And Flutter’s own team officially recommends it for any app that needs reliable navigation.
This flutter gorouter tutorial starts from zero and builds up to a complete app with named routes, parameter passing, auth guards, a persistent bottom navigation bar, and deep linking — all explained the way a beginner actually needs it.
- Flutter Navigation: Push, Pop, and Named Routes — understand what GoRouter replaces before using it
- StatefulWidget and setState — used in the auth guard section
- Know how to add packages to pubspec.yaml
- Optional but helpful: Flutter Bottom Navigation Bar — the ShellRoute section extends this
1. Why GoRouter? The Real Reason to Switch
Plain Navigator.push() works well for simple apps. Here’s exactly where it breaks down — and why each failure points to the same root cause:
| The problem | Why Navigator fails | GoRouter fix |
|---|---|---|
| Deep links open the wrong screen | Navigator has no URL concept — can’t map an incoming URL to a screen | Every GoRoute has a URL path — incoming URLs auto-match |
| Web URL bar doesn’t update | Navigator manages a widget stack, not URLs | Browser URL updates automatically on every navigation |
| Auth guard logic is scattered everywhere | Each screen checks auth independently — inconsistent | One global redirect function handles all auth |
| Context errors in async flows | Navigator.push(context, ...) needs a valid context that may be gone after async | GoRouter is accessed through the router config, not context lifecycle |
| Bottom nav tabs forget their scroll position | Switching tabs recreates widget trees — state is lost | StatefulShellRoute keeps each tab alive independently |
The reason Navigator has all these problems is that it is an imperative widget stack — screens have no names, no URLs, no addresses. GoRouter is declarative and URL-based. Every route is a path. That single change fixes all of the above at once.
No. Navigator 1.0 is never being deprecated — Flutter’s team confirmed this. GoRouter is an abstraction on top of Navigator 2.0 that lives alongside it. You can migrate screen by screen, and GoRouter’s
context.push() coexists with any remaining Navigator.push() calls during transition. That said, mixing the two after migrating causes stack corruption — commit to one approach per app.
2. Setup and Installation
# pubspec.yaml
dependencies:
go_router: ^14.0.0 # published by flutter.dev — check pub.dev for latest
flutter pub get
import 'package:go_router/go_router.dart';
The key setup change: replace MaterialApp with MaterialApp.router. This is the most important conceptual shift when coming from Navigator.push():
// ── BEFORE (plain Navigator) ──────────────────────────────────────────────
MaterialApp(
home: const HomeScreen(),
routes: {
'/profile': (_) => const ProfileScreen(),
'/settings': (_) => const SettingsScreen(),
},
)
// ── AFTER (GoRouter) ──────────────────────────────────────────────────────
MaterialApp.router(
routerConfig: _router, // ← all route definitions live here
)
3. Your First GoRouter: Define Routes and Navigate
Define the router as a top-level final variable — never inside a widget’s build() method. Creating a new router on every rebuild wipes navigation state entirely:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() {
runApp(const MyApp());
}
// ── Define the router at the top level (outside any widget) ───────────────
// Never define GoRouter inside build() — it would recreate on every rebuild
final GoRouter _router = GoRouter(
initialLocation: '/', // which route to show first
debugLogDiagnostics: true, // prints route activity in debug console — very useful!
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
],
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'GoRouter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
routerConfig: _router, // ← wire up the router
);
}
}
// ── Navigation in any widget ──────────────────────────────────────────────
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Center(
child: Column(
children: [
FilledButton(
onPressed: () => context.go('/profile'), // ← GoRouter navigation
child: const Text('Go to Profile'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => context.push('/settings'), // ← push navigation
child: const Text('Open Settings'),
),
],
),
),
],
),
);
}
}
4. Named Routes — Navigate Without String Literals
Assigning a name to each route lets you navigate by name instead of path string. This eliminates typos, is refactor-safe (rename the path without touching call sites), and enables passing typed parameters:
final GoRouter _router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
name: 'home', // ← assign a name
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/profile',
name: 'profile',
builder: (context, state) => const ProfileScreen(),
),
GoRoute(
path: '/product/:id',
name: 'product', // ← named route with path parameter
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductScreen(productId: id);
},
),
],
);
// ── Navigation with names ─────────────────────────────────────────────────
// By path string (works but fragile — typos are runtime errors)
context.go('/profile');
context.go('/product/42');
// By name (preferred — typos are compile errors with proper constants)
context.goNamed('profile');
context.goNamed('product', pathParameters: {'id': '42'});
context.pushNamed('settings');
// ── Define route name constants to get compile-time safety ─────────────────
class Routes {
Routes._();
static const home = 'home';
static const profile = 'profile';
static const product = 'product';
static const settings = 'settings';
}
// Usage — typos in Routes.xxx are caught at compile time:
context.goNamed(Routes.profile);
context.goNamed(Routes.product, pathParameters: {'id': productId});
5. go() vs push() vs pop() — The Complete Breakdown
This is the most misunderstood part of GoRouter. Getting it wrong means your back button vanishes or your navigation history behaves unexpectedly:
| Method | What it does to the stack | Back button | Use for |
|---|---|---|---|
context.go('/path') | Replaces the entire stack | No back — nothing behind it | Top-level navigation between app sections |
context.push('/path') | Adds on top of current stack | Yes — returns to previous screen | Drill-down detail screens |
context.pop() | Removes current screen from stack | Returns to whatever was below | Dismissing a pushed screen |
context.replace('/path') | Replaces only the current screen | Yes — goes back to what was below | Login → Home (don’t allow back to login) |
context.canPop() | Returns bool — doesn’t navigate | N/A | Check before popping to avoid underflow |
// ── go() — replaces the stack ─────────────────────────────────────────────
// Scenario: user taps "Home" in the bottom navigation bar
// Before: stack = [HomeScreen, SettingsScreen]
context.go('/home');
// After: stack = [HomeScreen] ← settings is gone, no back button
// go() is like typing a new URL in the browser — full navigation replace.
// User cannot go back to what they were on before.
// ─────────────────────────────────────────────────────────────────────────
// ── push() — adds to the stack ────────────────────────────────────────────
// Scenario: user taps a product in a list to see details
// Before: stack = [HomeScreen, ProductListScreen]
context.push('/product/42');
// After: stack = [HomeScreen, ProductListScreen, ProductDetailScreen]
// ↑ back button shows here
// push() is like opening a new tab — user can go back.
// ─────────────────────────────────────────────────────────────────────────
// ── replace() — swaps current screen ─────────────────────────────────────
// Scenario: user completes login, navigate to home WITHOUT allowing back to login
// Before: stack = [SplashScreen, LoginScreen]
context.replace('/home');
// After: stack = [SplashScreen, HomeScreen]
// ↑ LoginScreen is gone, back goes to Splash
// ─────────────────────────────────────────────────────────────────────────
// ── pop() with canPop() safety check ──────────────────────────────────────
void _handleBackButton(BuildContext context) {
if (context.canPop()) {
context.pop(); // ← safe: something is behind us
} else {
context.go('/home'); // ← nothing to pop: go to home instead
}
}
// ─────────────────────────────────────────────────────────────────────────
// ── The rule of thumb ─────────────────────────────────────────────────────
// go() → switching between main app sections (tabs, top-level screens)
// push() → drilling into a detail (list → detail, settings → sub-setting)
// replace()→ after auth flows where back-to-login makes no sense
On Flutter Web,
context.push() does not update the browser URL by default. Fix it by adding this line in main() before building the router:GoRouter.optionURLReflectsImperativeAPIs = true;
6. Passing Parameters: Path, Query, and Extra
GoRouter has three ways to pass data between routes. Each has different trade-offs — especially around deep linking:
Method 1: Path Parameters (embedded in the URL)
// ── Define the route with :paramName placeholders ─────────────────────────
GoRoute(
path: '/product/:productId',
name: 'product',
builder: (context, state) {
// pathParameters is always non-null when the path matched
final productId = state.pathParameters['productId']!;
return ProductScreen(productId: productId);
},
),
// Multiple path params:
GoRoute(
path: '/team/:teamId/member/:memberId',
builder: (context, state) {
final teamId = state.pathParameters['teamId']!;
final memberId = state.pathParameters['memberId']!;
return MemberScreen(teamId: teamId, memberId: memberId);
},
),
// ── Navigate with path params ─────────────────────────────────────────────
context.go('/product/42');
context.go('/team/flutter/member/alice');
// Named + typed:
context.goNamed('product', pathParameters: {'productId': '42'});
context.goNamed('member', pathParameters: {'teamId': 'flutter', 'memberId': 'alice'});
Method 2: Query Parameters (after the ? in the URL)
// ── No declaration needed in the path — just access state.uri ────────────
GoRoute(
path: '/products',
name: 'products',
builder: (context, state) {
// queryParameters are nullable — key may not be present
final category = state.uri.queryParameters['category']; // String?
final sort = state.uri.queryParameters['sort'] ?? 'asc'; // default
final page = int.tryParse(
state.uri.queryParameters['page'] ?? '1',
) ?? 1;
return ProductsScreen(category: category, sort: sort, page: page);
},
),
// ── Navigate with query params ────────────────────────────────────────────
context.go('/products?category=shoes&sort=price&page=2');
// Named + typed:
context.goNamed('products', queryParameters: {
'category': 'shoes',
'sort': 'price',
'page': '2',
});
Method 3: extra (Dart objects — not URL-safe)
// ── Pass any Dart object via extra ────────────────────────────────────────
// Useful for passing objects you already have in memory — avoids a second API call
// Navigate
context.push('/product-detail', extra: product); // passes a Product object
// Receive
GoRoute(
path: '/product-detail',
builder: (context, state) {
final product = state.extra as Product; // cast to your type
return ProductDetailScreen(product: product);
},
),
// ── CRITICAL WARNING: extra doesn't survive deep links ────────────────────
// If a user opens your app via a deep link like:
// yourapp://product-detail
// state.extra will be NULL — the URL has no way to encode a Dart object.
//
// Rule: use extra ONLY for in-app navigation where you already have the object.
// For any screen that must support deep linking → use path or query params.
// ── Combining all three ────────────────────────────────────────────────────
context.go(
'/products/42?referrer=home', // path param + query param
extra: {'scrollPosition': 120.0}, // extra for in-memory state
);
7. Nested (Sub) Routes
// Child routes are defined inside the parent's 'routes' list.
// Child paths are RELATIVE — no leading slash — they append to the parent path.
final GoRouter _router = GoRouter(
routes: [
GoRoute(
path: '/products', // → /products
name: 'products',
builder: (context, state) => const ProductListScreen(),
routes: [
GoRoute(
path: ':id', // → /products/:id
name: 'product-detail',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductDetailScreen(productId: id);
},
routes: [
GoRoute(
path: 'reviews', // → /products/:id/reviews
name: 'product-reviews',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductReviewsScreen(productId: id);
},
),
],
),
],
),
],
);
// Navigate to nested routes:
context.go('/products'); // product list
context.go('/products/42'); // product 42 detail
context.go('/products/42/reviews'); // product 42 reviews
// ❌ Common mistake: child path starts with /
routes: [
GoRoute(path: '/id', ...), // ← throws RouteException
]
// ✅ Correct: no leading slash on child paths
routes: [
GoRoute(path: ':id', ...), // ← relative path, becomes /products/:id
]
8. Redirects and Auth Guards
Redirects are GoRouter’s most powerful feature for production apps. Instead of checking auth in every screen’s initState, you define one redirect function that runs before every navigation event:
Global redirect (app-wide auth guard)
final GoRouter _router = GoRouter(
initialLocation: '/',
redirect: (BuildContext context, GoRouterState state) {
final isLoggedIn = AuthService.instance.isLoggedIn;
final isOnLoginPage = state.matchedLocation == '/login';
final isOnSplash = state.matchedLocation == '/splash';
// Not logged in and not already on login or splash:
if (!isLoggedIn && !isOnLoginPage && !isOnSplash) {
return '/login'; // ← redirect to login
}
// Already logged in but navigating to login (e.g., user typed the URL manually):
if (isLoggedIn && isOnLoginPage) {
return '/'; // ← redirect away from login
}
return null; // ← null means "proceed normally, no redirect"
},
routes: [
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
GoRoute(path: '/settings',builder: (_, __) => const SettingsScreen()),
],
);
If your redirect function says “if not logged in, go to /login” but doesn’t check that the user isn’t already on /login, navigating to /login triggers the redirect which sends to /login which triggers the redirect… infinite loop crash. Always check
state.matchedLocation != '/login' before redirecting to login.
Per-route redirect (role-based access)
GoRoute(
path: '/admin',
// This redirect only fires when navigating to /admin
redirect: (context, state) {
if (!AuthService.instance.isAdmin) {
return '/home'; // non-admins are redirected to home
}
return null; // admins proceed normally
},
builder: (context, state) => const AdminDashboard(),
),
// GoRouter evaluates redirects in this order:
// 1. Global redirect (fires for every navigation)
// 2. Per-route redirect (fires only for this specific route)
Reactive redirect with refreshListenable (the right way to handle logout)
// Problem: the redirect callback only fires when navigation happens.
// If the user's session expires while they're on a screen — no navigation
// occurs, so redirect never re-evaluates, user stays on a protected screen.
// Solution: refreshListenable
// GoRouter re-runs the redirect whenever the listenable notifies.
class AuthNotifier extends ChangeNotifier {
bool _isLoggedIn = false;
bool get isLoggedIn => _isLoggedIn;
void login() {
_isLoggedIn = true;
notifyListeners(); // ← triggers redirect re-evaluation
}
void logout() {
_isLoggedIn = false;
notifyListeners(); // ← triggers redirect re-evaluation → user sent to /login
}
}
final authNotifier = AuthNotifier();
final GoRouter _router = GoRouter(
refreshListenable: authNotifier, // ← watches this for changes
redirect: (context, state) {
if (!authNotifier.isLoggedIn && state.matchedLocation != '/login') {
return '/login';
}
if (authNotifier.isLoggedIn && state.matchedLocation == '/login') {
return '/';
}
return null;
},
routes: [...],
);
// Now when authNotifier.logout() is called anywhere in the app:
// → notifyListeners() fires
// → GoRouter re-evaluates the redirect
// → User is automatically sent to /login
// No manual context.go('/login') needed anywhere
9. Error Handling — Custom 404 Page
final GoRouter _router = GoRouter(
// errorBuilder fires when a route path doesn't match any GoRoute
// Critical for web apps where users can type arbitrary URLs
errorBuilder: (context, state) {
return Scaffold(
appBar: AppBar(title: const Text('Page Not Found')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.link_off, size: 72, color: Colors.grey),
const SizedBox(height: 20),
const Text(
'404 — Page Not Found',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Text(
'Could not find: ${state.uri}', // shows what URL was attempted
style: TextStyle(color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
if (state.error != null) ...[
const SizedBox(height: 8),
Text(
'${state.error}',
style: const TextStyle(color: Colors.red, fontSize: 12),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 28),
FilledButton.icon(
onPressed: () => context.go('/'),
icon: const Icon(Icons.home),
label: const Text('Go Home'),
),
],
),
),
);
},
routes: [...],
);
10. StatefulShellRoute: Persistent Bottom Navigation Bar
StatefulShellRoute.indexedStack is how GoRouter handles a bottom navigation bar where each tab keeps its state (scroll position, sub-navigation stack) when you switch between them. Without it, switching tabs destroys and recreates the previous tab completely.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// ── Each branch needs its own GlobalKey — critical, never share ───────────
final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _homeNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'home');
final _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search');
final _profileNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'profile');
final GoRouter _router = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/home',
routes: [
StatefulShellRoute.indexedStack(
// The builder wraps ALL tabs in a persistent scaffold
builder: (context, state, navigationShell) {
return ScaffoldWithNavBar(navigationShell: navigationShell);
},
branches: [
// ── Branch 1: Home ─────────────────────────────────────────────
StatefulShellBranch(
navigatorKey: _homeNavigatorKey,
routes: [
GoRoute(
path: '/home',
name: 'home',
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
path: 'article/:id', // → /home/article/:id
name: 'article',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ArticleScreen(articleId: id);
// ↑ navigating here shows back button, tab bar stays visible
},
),
],
),
],
),
// ── Branch 2: Search ────────────────────────────────────────────
StatefulShellBranch(
navigatorKey: _searchNavigatorKey,
routes: [
GoRoute(
path: '/search',
name: 'search',
builder: (context, state) => const SearchScreen(),
),
],
),
// ── Branch 3: Profile ───────────────────────────────────────────
StatefulShellBranch(
navigatorKey: _profileNavigatorKey,
routes: [
GoRoute(
path: '/profile',
name: 'profile',
builder: (context, state) => const ProfileScreen(),
routes: [
GoRoute(
path: 'settings', // → /profile/settings
name: 'profile-settings',
builder: (context, state) => const ProfileSettingsScreen(),
),
],
),
],
),
],
),
],
);
// ── The persistent shell scaffold ─────────────────────────────────────────
class ScaffoldWithNavBar extends StatelessWidget {
const ScaffoldWithNavBar({required this.navigationShell, super.key});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return Scaffold(
// navigationShell IS the body — it renders the active branch
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (index) {
navigationShell.goBranch(
index,
// When tapping the currently active tab's icon:
// initialLocation = true → pops back to the tab's root screen
// initialLocation = false → stays on the current sub-screen
initialLocation: index == navigationShell.currentIndex,
);
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Search',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Profile',
),
],
),
);
}
}
When a user taps the Home tab while already on the Home tab (having navigated to an article),
initialLocation: true makes GoRouter pop all the sub-routes and return to the tab’s root screen — exactly like Instagram’s tab behavior. Setting it to false instead would do nothing on a tap of the current tab.
11. Deep Linking Setup (Android and iOS)
GoRouter is URL-based by design — every route already has a path. To open those paths from outside the app (notifications, web links, emails), you need two things: platform-level config to intercept the URL, and GoRouter automatically maps it to the right screen.
Android (AndroidManifest.xml)
<!-- android/app/src/main/AndroidManifest.xml -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<!-- Required: tell Flutter that deep linking is enabled -->
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="true" />
<!-- HTTPS deep links (Android App Links — verified, no browser prompt) -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="yourapp.com" />
<!-- Opens https://yourapp.com/products/42 → GoRoute path: /products/:id -->
</intent-filter>
<!-- Custom URL scheme (simpler — no domain verification needed) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="myapp"
android:host="open" />
<!-- Opens myapp://open/products/42 → GoRoute path: /products/:id -->
</intent-filter>
</activity>
iOS (Info.plist)
<!-- ios/Runner/Info.plist -->
<!-- Required: enable Flutter deep linking -->
<key>FlutterDeepLinkingEnabled</key>
<true/>
<!-- Custom URL scheme (myapp://open/products/42) -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
<!-- For HTTPS Universal Links (yourapp.com/products/42):
Also add Associated Domains capability in Xcode:
applinks:yourapp.com -->
After platform setup, GoRouter handles the rest automatically. An incoming URL like https://yourapp.com/products/42 is parsed, matched against your route tree, and the correct screen opens with productId = '42' — zero extra Flutter code needed.
If a deep link arrives for
/profile and the user isn’t logged in, GoRouter’s global redirect fires first — the user is sent to /login. Save the intended destination with state.uri.toString() in your redirect and redirect to it after successful login.
12. Common Mistakes
| Mistake | What happens | Fix |
|---|---|---|
Using MaterialApp instead of MaterialApp.router | GoRouter is ignored entirely — app shows nothing or the wrong screen | Replace with MaterialApp.router(routerConfig: _router) |
Defining GoRouter inside build() | New router created on every rebuild — navigation state wiped | Define as final top-level variable outside any widget |
Child route path starting with / | GoRouter throws RouteException at startup | Child paths are relative — no leading slash: path: ':id' not path: '/:id' |
Using go() for detail screens that need a back button | Back button disappears — go() replaces the whole stack | Use push() for drill-down navigation where back is expected |
| Not guarding the login page in the redirect | Infinite redirect loop — app crashes | Always check state.matchedLocation != '/login' before redirecting to /login |
Using extra for deep-linkable data | extra is null when screen is opened via deep link | Use path or query params for any data that must survive deep linking |
Mixing Navigator.push() with context.go() | Stack corruption — unpredictable back button behavior | After migrating to GoRouter, use only GoRouter methods for navigation |
Sharing GlobalKey across StatefulShellBranches | Tab navigation state merges — wrong tabs show wrong content | Every branch needs its own unique GlobalKey<NavigatorState> |
Not setting optionURLReflectsImperativeAPIs on web | Browser URL bar doesn’t update when using push() | GoRouter.optionURLReflectsImperativeAPIs = true in main() |
13. Interview Q&A
A: GoRouter is a routing package published by the Flutter team (
flutter.dev) that sits on top of Navigator 2.0 and provides URL-based declarative routing. Flutter recommends it because it solves Navigator 1.0’s main weaknesses: no deep link support, no URL bar updates on web, scattered auth logic, and complex back-button behavior with nested navigators. GoRouter wraps all of Navigator 2.0’s complexity (RouterDelegate, RouteInformationParser) behind a simple API.
A:
context.go() replaces the entire navigation stack — like typing a new URL in a browser. The user cannot go back. Use it for top-level navigation between app sections. context.push() adds the new screen on top of the current stack — the back button is visible and returns to the previous screen. Use it for drill-down navigation like opening a detail screen from a list. The most common beginner mistake is using go() for a detail screen and then wondering why the back button disappeared.
A: Use the
redirect parameter on GoRouter. It fires before every navigation event. Return a path string to redirect, or null to allow. For reactive redirects that fire when auth state changes mid-session (e.g. session expiry), use refreshListenable with a ChangeNotifier that calls notifyListeners() on login and logout. This eliminates scattered context.go('/login') calls throughout your app — the router handles it automatically.
A:
StatefulShellRoute.indexedStack is how GoRouter builds a persistent bottom navigation bar where each tab maintains independent state. Without it, switching tabs destroys and recreates the tab’s widget tree — scroll position and sub-navigation are lost. With it, each branch has its own GlobalKey<NavigatorState> and its own Navigator stack, so tab state is preserved. You need it for any app with a bottom navigation bar where users should be able to navigate within a tab and return to where they were after switching tabs.
A: Path parameters (embedded in the URL as
:paramName — required, always non-null when matched), query parameters (appended as ?key=value — optional, nullable), and extra (any Dart object passed outside the URL). The critical distinction: path and query params survive deep links and page refreshes because they are encoded in the URL. extra is lost when a screen is opened via deep link — it cannot be reconstructed from a URL. Use extra only for in-app navigation where you already have the object in memory.
14. Related Posts
| Post | Why it’s relevant |
|---|---|
| Flutter Navigation: Push, Pop, and Named Routes | The prerequisite — understand what Navigator does before learning how GoRouter replaces it. |
| Flutter Bottom Navigation Bar | The StatefulShellRoute section directly extends this — add GoRouter to the nav bar patterns covered there. |
| Flutter Widgets: Stateless vs Stateful | The AuthNotifier pattern in the redirect section uses ChangeNotifier — this post covers the foundational concepts. |
| Flutter SharedPreferences Tutorial | The auth guard pattern here pairs naturally with storing login state in SharedPreferences — see how both fit together. |
| Understanding pubspec.yaml | Adding go_router requires editing pubspec.yaml — this guide covers every aspect of that file. |

Pingback: Flutter Riverpod for Beginners: Ditch setState and Never Look Back