Lesson 14 of 77 intermediate

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

Navigator 1.0 is like a stack of plates — you push new plates on top and pop them off. Navigator 2.0 with GoRouter is like a GPS system — you tell it your destination URL and it figures out the whole route, handles detours (redirects), and even knows how to get there from outside the app (deep links).

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

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. 1. AuthNotifier extends ChangeNotifier — notifyListeners triggers GoRouter redirect re-evaluation
  2. 2. refreshListenable: authNotifier — GoRouter watches this for changes
  3. 3. redirect: runs before every navigation — null means proceed, string means redirect
  4. 4. Guard: not logged in + not on login → redirect to login with 'from' param
  5. 5. Guard: logged in + on login → redirect to dashboard (prevent login loop)
  6. 6. ShellRoute: shared AppShell layout wrapping nested routes
  7. 7. GoRoute with :id path parameter — parsed as pathParameters['id']
  8. 8. Nested routes: /tasks/:id/edit is a child of /tasks/:id
  9. 9. context.go() replaces stack — no back button to previous
  10. 10. context.push() adds to stack — back button returns to caller
  11. 11. goNamed() uses route name instead of path string — prevents typos
  12. 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?
What happens when the user is not logged in and navigates to /login?
Show answer
Infinite redirect loop: user not logged in → redirect to '/login' → redirect runs again → user still not logged in → redirect to '/login' → infinite loop. Fix: add a condition — if (!isLoggedIn && state.matchedLocation != '/login') return '/login'; return null; — only redirect if NOT already on the login page.

Explain like I'm 5

Navigator 1.0 is like a stack of books — put a book on top, take it off when done. GoRouter is like a map app for your app — you say 'I want to go to /tasks/42' and it figures out the whole journey, can redirect you if you're not allowed somewhere (route guard), and can start at the right page if someone shares a link with you (deep link). ShellRoute is like having the same navigation bar at the bottom while different pages appear in the middle.

Fun fact

Navigator 2.0's declarative API was so complex that the Flutter team and community quickly built GoRouter to make it usable. The original Navigator 2.0 API required implementing RouterDelegate, RouteInformationParser, and RouteInformationProvider separately — each with multiple override methods. GoRouter reduces this to a list of GoRoute objects. In 2023, GoRouter was officially moved into the flutter/packages repo, becoming the de facto standard.

Hands-on challenge

Build a GoRouter setup with: /login (public), /app/home (guarded), /app/profile/:userId (guarded). Add a redirect that checks a simulated auth state. Test that navigating to /app/home when unauthenticated redirects to /login?from=/app/home, and that after 'logging in', the app redirects back to /app/home.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery