Navigation: Navigator vs GoRouter, Deep Links & Route Guards
Every Senior Flutter Dev Gets Asked This — Answer With Confidence
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Flutter has two navigation APIs: Navigator 1.0 (imperative push/pop stack) and Navigator 2.0 (declarative pages/Router). GoRouter is the standard library that abstracts Navigator 2.0 into a URL-based routing system with deep link support, route guards via redirect, nested navigation via ShellRoute, and typed routes.
Real-world relevance
In a SaaS collaboration app, GoRouter manages: /login (guard: redirect authenticated users to /dashboard), /dashboard (shell with bottom nav), /dashboard/tasks/:id (task detail), /dashboard/tasks/:id/edit (nested edit flow). Deep links from email notifications open /tasks/42 directly. The auth guard uses a Riverpod provider as refreshListenable to react to login/logout.
Key points
- Navigator 1.0 — Imperative Stack — Navigator.push() and Navigator.pop() manage a stack of routes. Simple and intuitive. Works well for linear flows. Limitations: no URL support, deep linking is manual, web navigation history doesn't work correctly. For simple apps or internal flows, Navigator 1.0 remains appropriate.
- Navigator 2.0 — Declarative Pages API — Navigator 2.0 (Router widget) is declarative: you declare which pages should be on the stack, and Flutter manages transitions. More complex but enables: URL-based navigation, proper back-button handling on web/Android, deep links. GoRouter is the most popular abstraction over Navigator 2.0.
- GoRouter — The Standard Solution — GoRouter maps URL paths to screens. Define routes with GoRoute('/path', builder:). Navigate with context.go('/path') or context.push('/path'). go() replaces the current page, push() adds to stack. GoRouter handles URL parsing, named routes, path parameters (:id), query parameters, and redirects.
- Deep Linking — iOS and Android Setup — Deep links let external URLs open specific screens in your app. iOS: configure Info.plist URL schemes (custom scheme) or Universal Links (https). Android: configure AndroidManifest.xml intent-filters with scheme, host, pathPrefix. Test with: adb shell am start -a android.intent.action.VIEW -d 'myapp://tasks/42'. GoRouter auto-handles deep link parsing.
- Route Guards with redirect — GoRouter's redirect callback runs before navigation. Check auth state, return null to proceed, return a new path to redirect. Example: if user not logged in and going to '/dashboard', redirect to '/login'. Combine with async auth stream using refreshListenable so guards update when auth state changes.
- Nested Navigation — Nested navigation = separate NavigatorStacks inside a parent route. Used for: bottom nav tabs (each tab has its own stack), embedded flows (checkout within a product page). GoRouter supports ShellRoute for shared layout with nested routing. Each tab maintains its own back stack.
- Named Routes vs Path Routes vs Typed Routes — Named routes: goNamed('task-detail', params: {'id': '42'}). Path routes: context.go('/tasks/42'). Typed routes (go_router_builder): TaskDetailRoute(id: '42').go(context) — compile-safe, no string typos. Typed routes are recommended for large apps. Interview: How do you prevent navigation string typos?
- Passing Data Between Routes — Simple data: encode in path params ('/tasks/:id') or query params. Complex data: use extra parameter in GoRouter (not URL-safe, lost on deep link). Best practice: pass only IDs in URLs, load full data from state/repository in the destination screen. Deep links can only carry what's in the URL.
- NavigationObserver — Analytics & Logging — NavigationObserver tracks route changes. Override didPush, didPop, didReplace. Register with navigatorObservers in MaterialApp or GoRouter. Use for: Firebase Analytics screen tracking, debugging navigation flow, analytics events. Interview: How do you track screen views in Flutter?
- Common Navigation Interview Scenarios — 'Back button closes app instead of going back' — check your Navigator stack, ensure routes are pushed not replaced. 'Deep link opens wrong screen' — verify GoRouter route hierarchy. 'Auth guard causes infinite redirect' — ensure redirecting to '/login' doesn't redirect again when already on '/login'.
Code example
// GoRouter — Navigation, Deep Links, Route Guards
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// --- AUTH NOTIFIER (for route guard) ---
class AuthNotifier extends ChangeNotifier {
bool _isAuthenticated = false;
bool get isAuthenticated => _isAuthenticated;
void login() {
_isAuthenticated = true;
notifyListeners(); // Triggers GoRouter to re-evaluate redirects
}
void logout() {
_isAuthenticated = false;
notifyListeners();
}
}
// --- ROUTER CONFIGURATION ---
GoRouter createRouter(AuthNotifier authNotifier) {
return GoRouter(
initialLocation: '/dashboard',
// refreshListenable: re-run redirects when auth state changes
refreshListenable: authNotifier,
// --- ROUTE GUARD ---
redirect: (context, state) {
final isLoggedIn = authNotifier.isAuthenticated;
final isOnLogin = state.matchedLocation == '/login';
if (!isLoggedIn && !isOnLogin) {
// Not logged in and not on login → redirect to login
return '/login?from=${state.matchedLocation}';
}
if (isLoggedIn && isOnLogin) {
// Logged in and on login → redirect to dashboard
return '/dashboard';
}
return null; // null = proceed normally
},
routes: [
// --- SIMPLE ROUTES ---
GoRoute(
path: '/login',
builder: (context, state) {
final from = state.uri.queryParameters['from'];
return LoginScreen(redirectAfterLogin: from);
},
),
// --- SHELL ROUTE: Shared layout with nested navigation ---
ShellRoute(
builder: (context, state, child) {
return AppShell(child: child); // AppShell has bottom nav
},
routes: [
GoRoute(
path: '/dashboard',
builder: (context, state) => const DashboardScreen(),
),
// --- NESTED ROUTE with PATH PARAMETER ---
GoRoute(
path: '/tasks',
builder: (context, state) => const TaskListScreen(),
routes: [
GoRoute(
path: ':id', // Matches /tasks/42
builder: (context, state) {
final taskId = state.pathParameters['id']!;
return TaskDetailScreen(taskId: taskId);
},
routes: [
GoRoute(
path: 'edit', // Matches /tasks/42/edit
builder: (context, state) {
final taskId = state.pathParameters['id']!;
return TaskEditScreen(taskId: taskId);
},
),
],
),
],
),
],
),
],
);
}
// --- NAVIGATION CALLS ---
class NavigationExamples extends StatelessWidget {
const NavigationExamples({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
// go() — replace current route (no back button to previous)
ElevatedButton(
onPressed: () => context.go('/dashboard'),
child: const Text('Go to Dashboard'),
),
// push() — add to stack (back button returns to here)
ElevatedButton(
onPressed: () => context.push('/tasks/42'),
child: const Text('Open Task 42'),
),
// goNamed() — use named routes to avoid string typos
ElevatedButton(
onPressed: () => context.goNamed(
'task-detail',
pathParameters: {'id': '42'},
queryParameters: {'highlight': 'true'},
),
child: const Text('Named Route'),
),
// Pass complex data via extra (not URL-safe, lost on deep link)
ElevatedButton(
onPressed: () => context.push(
'/tasks/42',
extra: {'prefetchedTask': 'someData'}, // Use carefully
),
child: const Text('With Extra Data'),
),
// Pop with result
ElevatedButton(
onPressed: () => context.pop({'saved': true}),
child: const Text('Save and Pop'),
),
],
);
}
}
// --- NAVIGATION OBSERVER: Screen analytics ---
class AnalyticsObserver extends NavigatorObserver {
@override
void didPush(Route route, Route? previousRoute) {
super.didPush(route, previousRoute);
final name = route.settings.name;
if (name != null) {
// FirebaseAnalytics.instance.logScreenView(screenName: name);
debugPrint('Screen: $name');
}
}
}
// --- PLACEHOLDER SCREENS ---
class LoginScreen extends StatelessWidget {
final String? redirectAfterLogin;
const LoginScreen({super.key, this.redirectAfterLogin});
@override
Widget build(BuildContext context) => const Scaffold(body: Center(child: Text('Login')));
}
class AppShell extends StatelessWidget {
final Widget child;
const AppShell({super.key, required this.child});
@override
Widget build(BuildContext context) => Scaffold(body: child);
}
class DashboardScreen extends StatelessWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context) => const Center(child: Text('Dashboard'));
}
class TaskListScreen extends StatelessWidget {
const TaskListScreen({super.key});
@override
Widget build(BuildContext context) => const Center(child: Text('Tasks'));
}
class TaskDetailScreen extends StatelessWidget {
final String taskId;
const TaskDetailScreen({super.key, required this.taskId});
@override
Widget build(BuildContext context) => Center(child: Text('Task $taskId'));
}
class TaskEditScreen extends StatelessWidget {
final String taskId;
const TaskEditScreen({super.key, required this.taskId});
@override
Widget build(BuildContext context) => Center(child: Text('Edit Task $taskId'));
}Line-by-line walkthrough
- 1. AuthNotifier extends ChangeNotifier — notifyListeners triggers GoRouter redirect re-evaluation
- 2. refreshListenable: authNotifier — GoRouter watches this for changes
- 3. redirect: runs before every navigation — null means proceed, string means redirect
- 4. Guard: not logged in + not on login → redirect to login with 'from' param
- 5. Guard: logged in + on login → redirect to dashboard (prevent login loop)
- 6. ShellRoute: shared AppShell layout wrapping nested routes
- 7. GoRoute with :id path parameter — parsed as pathParameters['id']
- 8. Nested routes: /tasks/:id/edit is a child of /tasks/:id
- 9. context.go() replaces stack — no back button to previous
- 10. context.push() adds to stack — back button returns to caller
- 11. goNamed() uses route name instead of path string — prevents typos
- 12. extra: passes non-URL data — NOT safe for deep links
Spot the bug
final router = GoRouter(
redirect: (context, state) {
if (!isLoggedIn) return '/login';
return null;
},
routes: [
GoRoute(path: '/login', builder: (c, s) => LoginScreen()),
GoRoute(path: '/home', builder: (c, s) => HomeScreen()),
],
);Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- GoRouter Documentation (pub.dev)
- Flutter Navigation (Flutter Official)
- Deep Links in Flutter (Flutter Official)
- GoRouter Examples (GitHub)