Capstone: Real-World Flutter Project

Navigation & Deep Linking with GoRouter

16 min Lesson 5 of 10

Navigation & Deep Linking with GoRouter

GoRouter is the officially recommended declarative routing package for Flutter. It replaces the low-level Navigator API with a URL-based, declarative configuration that supports deep links, web URLs, nested navigation for bottom tabs, and route guards — all in one coherent model. In this lesson you will configure GoRouter from scratch, protect routes with an auth redirect, wire up nested navigation for a tabbed shell, and handle deep links arriving from external sources.

1. Setting Up GoRouter

Add the dependency and create a single router instance, typically in its own file so it can be imported anywhere without circular dependencies.

pubspec.yaml & router configuration

# pubspec.yaml
dependencies:
  go_router: ^13.2.0

# ─────────────────────────────────────────────
# lib/router/app_router.dart

import 'package:go_router/go_router.dart';
import 'package:flutter/material.dart';

final GoRouter appRouter = GoRouter(
  initialLocation: '/home',
  routes: [
    GoRoute(
      path: '/login',
      name: 'login',
      builder: (context, state) => const LoginScreen(),
    ),
    GoRoute(
      path: '/home',
      name: 'home',
      builder: (context, state) => const HomeScreen(),
      routes: [
        GoRoute(
          path: 'detail/:id',   // becomes /home/detail/:id
          name: 'detail',
          builder: (context, state) {
            final id = state.pathParameters['id']!;
            return DetailScreen(itemId: id);
          },
        ),
      ],
    ),
    GoRoute(
      path: '/profile',
      name: 'profile',
      builder: (context, state) => const ProfileScreen(),
    ),
  ],
);

Pass the router to MaterialApp.router instead of the regular MaterialApp:

main.dart — wiring GoRouter into the app

import 'package:flutter/material.dart';
import 'router/app_router.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'GoRouter Demo',
      routerConfig: appRouter,   // <— the single router instance
    );
  }
}
Note: GoRouter replaces both MaterialApp(home:) and MaterialApp(routes:). Once you switch to MaterialApp.router, all navigation must go through the GoRouter instance — mixing the two leads to conflicts.

2. Named Routes & Typed Parameters

Using name: on every route lets you navigate without hard-coding URL strings, which prevents typos and makes refactoring safe.

  • context.goNamed('detail', pathParameters: {'id': '42'}) — replaces the current stack entry
  • context.pushNamed('detail', pathParameters: {'id': '42'}) — pushes on top of the stack
  • state.pathParameters['id'] — reads a URL segment declared as :id
  • state.uri.queryParameters['q'] — reads a query string like ?q=flutter

3. Route Guards — Protecting Auth-Required Routes

GoRouter's redirect callback runs before every navigation event. Returning a new path redirects the user; returning null allows the navigation to proceed. The cleanest pattern is to listen to an auth stream via refreshListenable so that changes to login state automatically re-evaluate all redirects.

Auth guard with refreshListenable

import 'package:flutter_riverpod/flutter_riverpod.dart';

// A ChangeNotifier that wraps your auth state
class AuthNotifier extends ChangeNotifier {
  bool _isLoggedIn = false;
  bool get isLoggedIn => _isLoggedIn;

  void login()  { _isLoggedIn = true;  notifyListeners(); }
  void logout() { _isLoggedIn = false; notifyListeners(); }
}

final authNotifier = AuthNotifier();

// ── router with global redirect ──────────────────────────────
final GoRouter appRouter = GoRouter(
  initialLocation: '/home',
  refreshListenable: authNotifier,   // re-runs redirect on auth change
  redirect: (context, state) {
    final loggedIn    = authNotifier.isLoggedIn;
    final onLoginPage = state.matchedLocation == '/login';

    if (!loggedIn && !onLoginPage) return '/login';
    if (loggedIn  &&  onLoginPage) return '/home';
    return null;   // allow navigation
  },
  routes: [ /* ... */ ],
);
Tip: refreshListenable accepts any Listenable, so you can pass a Riverpod ProviderListenable, a ValueNotifier, or merge multiple notifiers with Listenable.merge([a, b]).

4. Nested Navigation with ShellRoute (Bottom Tabs)

ShellRoute wraps a group of routes inside a persistent shell widget — perfect for bottom navigation bars where tabs share a common scaffold but each tab maintains its own navigation stack.

ShellRoute for tabbed navigation

import 'package:go_router/go_router.dart';

final GoRouter appRouter = GoRouter(
  initialLocation: '/feed',
  routes: [
    ShellRoute(
      builder: (context, state, child) => AppShell(child: child),
      routes: [
        GoRoute(
          path: '/feed',
          name: 'feed',
          builder: (_, __) => const FeedScreen(),
          routes: [
            GoRoute(
              path: 'post/:postId',
              name: 'post',
              builder: (context, state) => PostScreen(
                postId: state.pathParameters['postId']!,
              ),
            ),
          ],
        ),
        GoRoute(
          path: '/search',
          name: 'search',
          builder: (_, __) => const SearchScreen(),
        ),
        GoRoute(
          path: '/profile',
          name: 'profile',
          builder: (_, __) => const ProfileScreen(),
        ),
      ],
    ),
  ],
);

// The shell widget that holds the BottomNavigationBar
class AppShell extends StatelessWidget {
  final Widget child;
  const AppShell({super.key, required this.child});

  static const _tabs = ['/feed', '/search', '/profile'];

  @override
  Widget build(BuildContext context) {
    final location = GoRouterState.of(context).matchedLocation;
    final index    = _tabs.indexWhere((t) => location.startsWith(t));

    return Scaffold(
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: index < 0 ? 0 : index,
        onTap: (i) => context.go(_tabs[i]),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home),   label: 'Feed'),
          BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    );
  }
}

5. Handling Deep Links from External Sources

Deep links are URLs that open the app and navigate directly to a specific screen — e.g., a push notification that opens myapp://post/123, or a web link that launches the Flutter web build at https://myapp.com/post/123. GoRouter handles both automatically once you configure the URL scheme in your platform files.

  • Android: add an <intent-filter> with your scheme in AndroidManifest.xml
  • iOS: add a CFBundleURLTypes entry (custom scheme) or Associated Domains (universal links) in Info.plist
  • Flutter Web: works out of the box — GoRouter reads the browser URL path automatically
Warning: When a deep link arrives at a protected route (e.g., /profile) and the user is not authenticated, your redirect callback must redirect to /login AND store the originally requested path so you can redirect back after login. Store it in the query parameter: redirect to /login?redirect=/profile, then read state.uri.queryParameters['redirect'] after successful login.

Summary

GoRouter brings URL-first, declarative navigation to Flutter. The core concepts are: a single GoRouter instance with named GoRoutes, a global redirect callback powered by refreshListenable for auth guards, ShellRoute for persistent bottom-tab shells with nested stacks, and zero-configuration deep-link handling once platform URL schemes are registered. These building blocks are sufficient for any production-grade navigation architecture.