DartFlutter

Flutter Navigation 2.0 vs GoRouter: Which One Should You Use?

20250408 0947 Mixed Arrows At GoRouter Remix 01jra5w92ne0e8kqv7ej4qh13v 1024x683

Introduction

Flutter offers multiple ways to manage navigation—and as apps grow in complexity, choosing the right system becomes essential. The transition from simple Navigator.push() calls to a declarative routing system can dramatically improve maintainability, deep linking support, and web compatibility.

In this comprehensive guide, we’ll compare Flutter Navigation 2.0 with GoRouter, helping you decide which one fits your project best. We’ll cover everything from basic setup to advanced patterns like authentication guards, nested navigation, and state preservation.

What Is Flutter Navigation 2.0?

Flutter’s Navigation 2.0 system is a declarative, flexible API introduced to give developers full control over navigation state. Unlike Navigation 1.0’s imperative approach (Navigator.push/pop), Navigation 2.0 treats routes as state that can be fully controlled by your application.

Key Features:

  • Declarative routing: Define routes based on application state
  • URL sync support: Essential for Flutter web applications
  • Back/forward browser button handling: Proper history management
  • Full control via Router, RouteInformationParser, and RouterDelegate
// lib/navigation/app_router_delegate.dart
import 'package:flutter/material.dart';

// The RouterDelegate controls what pages are displayed
class AppRouterDelegate extends RouterDelegate
    with ChangeNotifier, PopNavigatorRouterDelegateMixin {
  
  @override
  final GlobalKey navigatorKey = GlobalKey();

  // Application state that determines navigation
  bool _isLoggedIn = false;
  String? _selectedProductId;
  bool _showProductDetails = false;
  bool _show404 = false;

  // Getters and setters that notify listeners
  bool get isLoggedIn => _isLoggedIn;
  set isLoggedIn(bool value) {
    _isLoggedIn = value;
    notifyListeners();
  }

  String? get selectedProductId => _selectedProductId;
  set selectedProductId(String? value) {
    _selectedProductId = value;
    _showProductDetails = value != null;
    notifyListeners();
  }

  @override
  AppRoutePath get currentConfiguration {
    if (_show404) return AppRoutePath.unknown();
    if (!_isLoggedIn) return AppRoutePath.login();
    if (_selectedProductId != null) {
      return AppRoutePath.productDetails(_selectedProductId!);
    }
    return AppRoutePath.home();
  }

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        // Always show login if not authenticated
        if (!_isLoggedIn)
          const MaterialPage(
            key: ValueKey('LoginPage'),
            child: LoginScreen(),
          ),
        
        // Show home page when logged in
        if (_isLoggedIn)
          const MaterialPage(
            key: ValueKey('HomePage'),
            child: HomeScreen(),
          ),
        
        // Show product details on top of home
        if (_isLoggedIn && _showProductDetails && _selectedProductId != null)
          MaterialPage(
            key: ValueKey('ProductPage-$_selectedProductId'),
            child: ProductDetailsScreen(productId: _selectedProductId!),
          ),
        
        // Show 404 page for unknown routes
        if (_show404)
          const MaterialPage(
            key: ValueKey('404Page'),
            child: NotFoundScreen(),
          ),
      ],
      onPopPage: (route, result) {
        if (!route.didPop(result)) return false;
        
        // Handle back navigation
        if (_selectedProductId != null) {
          _selectedProductId = null;
          _showProductDetails = false;
          notifyListeners();
          return true;
        }
        
        return true;
      },
    );
  }

  @override
  Future setNewRoutePath(AppRoutePath configuration) async {
    _show404 = configuration.isUnknown;
    
    if (configuration.isLoginPage) {
      _isLoggedIn = false;
      _selectedProductId = null;
    } else if (configuration.isHomePage) {
      _selectedProductId = null;
    } else if (configuration.isProductDetailsPage) {
      _selectedProductId = configuration.productId;
    }
  }
}

// Route path representation
class AppRoutePath {
  final String? productId;
  final bool isUnknown;
  final bool isLoginPage;

  AppRoutePath.home()
      : productId = null,
        isUnknown = false,
        isLoginPage = false;

  AppRoutePath.login()
      : productId = null,
        isUnknown = false,
        isLoginPage = true;

  AppRoutePath.productDetails(String this.productId)
      : isUnknown = false,
        isLoginPage = false;

  AppRoutePath.unknown()
      : productId = null,
        isUnknown = true,
        isLoginPage = false;

  bool get isHomePage => !isLoginPage && !isUnknown && productId == null;
  bool get isProductDetailsPage => productId != null;
}
// lib/navigation/app_route_parser.dart
import 'package:flutter/material.dart';

// Parses URLs into route paths and vice versa
class AppRouteInformationParser extends RouteInformationParser {
  
  @override
  Future parseRouteInformation(
    RouteInformation routeInformation,
  ) async {
    final uri = Uri.parse(routeInformation.location ?? '/');
    
    // Handle root path
    if (uri.pathSegments.isEmpty) {
      return AppRoutePath.home();
    }
    
    // Handle /login
    if (uri.pathSegments.length == 1 && uri.pathSegments[0] == 'login') {
      return AppRoutePath.login();
    }
    
    // Handle /products/:id
    if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'products') {
      final productId = uri.pathSegments[1];
      return AppRoutePath.productDetails(productId);
    }
    
    // Unknown route
    return AppRoutePath.unknown();
  }

  @override
  RouteInformation? restoreRouteInformation(AppRoutePath configuration) {
    if (configuration.isLoginPage) {
      return const RouteInformation(location: '/login');
    }
    if (configuration.isHomePage) {
      return const RouteInformation(location: '/');
    }
    if (configuration.isProductDetailsPage) {
      return RouteInformation(location: '/products/${configuration.productId}');
    }
    return const RouteInformation(location: '/404');
  }
}

// Usage in main.dart
class MyApp extends StatefulWidget {
  @override
  State createState() => _MyAppState();
}

class _MyAppState extends State {
  final AppRouterDelegate _routerDelegate = AppRouterDelegate();
  final AppRouteInformationParser _routeParser = AppRouteInformationParser();

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Navigation 2.0 Demo',
      routerDelegate: _routerDelegate,
      routeInformationParser: _routeParser,
    );
  }
}

Pros of Navigation 2.0:

  • Full control over every aspect of routing logic
  • Perfect for advanced web apps and custom navigation flows
  • Deep link support with complete URL control
  • State-driven navigation matches well with state management solutions

Cons of Navigation 2.0:

  • Significant boilerplate code required
  • Complex for beginners to understand and implement
  • Requires manual state synchronization
  • Debugging can be challenging

What Is GoRouter?

GoRouter is an officially supported routing package built on top of Navigation 2.0. It abstracts away the complexity while keeping the power of the underlying API. Maintained by the Flutter team, it’s the recommended solution for most applications.

GoRouter Setup and Basic Configuration

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  go_router: ^14.0.0
  riverpod: ^2.5.0  # Optional: for state management integration
// lib/router/app_router.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

// Route path constants for type safety
class AppRoutes {
  static const home = '/';
  static const login = '/login';
  static const register = '/register';
  static const products = '/products';
  static const productDetails = '/products/:productId';
  static const cart = '/cart';
  static const checkout = '/checkout';
  static const profile = '/profile';
  static const settings = '/profile/settings';
  static const orders = '/profile/orders';
  static const orderDetails = '/profile/orders/:orderId';
}

// Router configuration
class AppRouter {
  final AuthService authService;
  
  AppRouter({required this.authService});
  
  late final GoRouter router = GoRouter(
    initialLocation: AppRoutes.home,
    debugLogDiagnostics: true, // Enable for development
    
    // Global redirect for authentication
    redirect: (context, state) {
      final isLoggedIn = authService.isAuthenticated;
      final isLoggingIn = state.matchedLocation == AppRoutes.login;
      final isRegistering = state.matchedLocation == AppRoutes.register;
      
      // Redirect to login if not authenticated and not on auth pages
      if (!isLoggedIn && !isLoggingIn && !isRegistering) {
        // Save the intended destination
        return '${AppRoutes.login}?redirect=${state.matchedLocation}';
      }
      
      // Redirect away from login if already authenticated
      if (isLoggedIn && (isLoggingIn || isRegistering)) {
        return AppRoutes.home;
      }
      
      return null; // No redirect needed
    },
    
    // Refresh router when auth state changes
    refreshListenable: authService,
    
    // Error page for unknown routes
    errorBuilder: (context, state) => NotFoundScreen(
      error: state.error?.toString(),
    ),
    
    routes: [
      // Home route
      GoRoute(
        path: AppRoutes.home,
        name: 'home',
        builder: (context, state) => const HomeScreen(),
      ),
      
      // Auth routes
      GoRoute(
        path: AppRoutes.login,
        name: 'login',
        builder: (context, state) {
          final redirect = state.uri.queryParameters['redirect'];
          return LoginScreen(redirectPath: redirect);
        },
      ),
      GoRoute(
        path: AppRoutes.register,
        name: 'register',
        builder: (context, state) => const RegisterScreen(),
      ),
      
      // Products routes
      GoRoute(
        path: AppRoutes.products,
        name: 'products',
        builder: (context, state) => const ProductsScreen(),
        routes: [
          // Nested route: /products/:productId
          GoRoute(
            path: ':productId',
            name: 'product-details',
            builder: (context, state) {
              final productId = state.pathParameters['productId']!;
              return ProductDetailsScreen(productId: productId);
            },
          ),
        ],
      ),
      
      // Cart and checkout
      GoRoute(
        path: AppRoutes.cart,
        name: 'cart',
        builder: (context, state) => const CartScreen(),
      ),
      GoRoute(
        path: AppRoutes.checkout,
        name: 'checkout',
        builder: (context, state) => const CheckoutScreen(),
      ),
      
      // Profile section with nested routes
      GoRoute(
        path: AppRoutes.profile,
        name: 'profile',
        builder: (context, state) => const ProfileScreen(),
        routes: [
          GoRoute(
            path: 'settings',
            name: 'settings',
            builder: (context, state) => const SettingsScreen(),
          ),
          GoRoute(
            path: 'orders',
            name: 'orders',
            builder: (context, state) => const OrdersScreen(),
            routes: [
              GoRoute(
                path: ':orderId',
                name: 'order-details',
                builder: (context, state) {
                  final orderId = state.pathParameters['orderId']!;
                  return OrderDetailsScreen(orderId: orderId);
                },
              ),
            ],
          ),
        ],
      ),
    ],
  );
}

Shell Routes for Bottom Navigation

GoRouter’s ShellRoute enables persistent UI elements like bottom navigation bars:

// lib/router/shell_router.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class AppRouterWithShell {
  late final GoRouter router = GoRouter(
    initialLocation: '/home',
    routes: [
      // Login route outside the shell
      GoRoute(
        path: '/login',
        builder: (context, state) => const LoginScreen(),
      ),
      
      // Shell route wraps all main app content
      ShellRoute(
        builder: (context, state, child) {
          return ScaffoldWithNavBar(child: child);
        },
        routes: [
          GoRoute(
            path: '/home',
            name: 'home',
            pageBuilder: (context, state) => const NoTransitionPage(
              child: HomeScreen(),
            ),
          ),
          GoRoute(
            path: '/search',
            name: 'search',
            pageBuilder: (context, state) => const NoTransitionPage(
              child: SearchScreen(),
            ),
          ),
          GoRoute(
            path: '/cart',
            name: 'cart',
            pageBuilder: (context, state) => const NoTransitionPage(
              child: CartScreen(),
            ),
          ),
          GoRoute(
            path: '/profile',
            name: 'profile',
            pageBuilder: (context, state) => const NoTransitionPage(
              child: ProfileScreen(),
            ),
            routes: [
              // This route still shows within the shell
              GoRoute(
                path: 'settings',
                builder: (context, state) => const SettingsScreen(),
              ),
            ],
          ),
        ],
      ),
      
      // Full-screen routes outside the shell
      GoRoute(
        path: '/products/:id',
        builder: (context, state) => ProductDetailsScreen(
          productId: state.pathParameters['id']!,
        ),
      ),
    ],
  );
}

// Scaffold with persistent bottom navigation
class ScaffoldWithNavBar extends StatelessWidget {
  final Widget child;
  
  const ScaffoldWithNavBar({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _calculateSelectedIndex(context),
        onTap: (index) => _onItemTapped(index, context),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
          BottomNavigationBarItem(icon: Icon(Icons.shopping_cart), label: 'Cart'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    );
  }

  int _calculateSelectedIndex(BuildContext context) {
    final location = GoRouterState.of(context).matchedLocation;
    if (location.startsWith('/home')) return 0;
    if (location.startsWith('/search')) return 1;
    if (location.startsWith('/cart')) return 2;
    if (location.startsWith('/profile')) return 3;
    return 0;
  }

  void _onItemTapped(int index, BuildContext context) {
    switch (index) {
      case 0:
        context.go('/home');
        break;
      case 1:
        context.go('/search');
        break;
      case 2:
        context.go('/cart');
        break;
      case 3:
        context.go('/profile');
        break;
    }
  }
}
// Different navigation methods and when to use them

class NavigationExamples extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // go() - Replaces current route stack to match the path
        // Use for tab navigation or when you want to clear history
        ElevatedButton(
          onPressed: () => context.go('/products'),
          child: const Text('Go to Products'),
        ),
        
        // push() - Adds a new route on top of the stack
        // Use when you want back navigation to return here
        ElevatedButton(
          onPressed: () => context.push('/products/123'),
          child: const Text('Push Product Details'),
        ),
        
        // pushNamed() - Push using route name
        ElevatedButton(
          onPressed: () => context.pushNamed(
            'product-details',
            pathParameters: {'productId': '123'},
            queryParameters: {'highlight': 'true'},
          ),
          child: const Text('Push Named Route'),
        ),
        
        // pop() - Go back to previous route
        ElevatedButton(
          onPressed: () => context.pop(),
          child: const Text('Go Back'),
        ),
        
        // pop() with result - Return data to previous screen
        ElevatedButton(
          onPressed: () => context.pop({'selected': true}),
          child: const Text('Go Back with Result'),
        ),
        
        // pushReplacement() - Replace current route without back
        ElevatedButton(
          onPressed: () => context.pushReplacement('/home'),
          child: const Text('Replace Current Route'),
        ),
        
        // goNamed() with extra data
        ElevatedButton(
          onPressed: () => context.goNamed(
            'product-details',
            pathParameters: {'productId': '123'},
            extra: Product(id: '123', name: 'Widget'),  // Pass complex objects
          ),
          child: const Text('Go with Extra Data'),
        ),
      ],
    );
  }
}

Advanced GoRouter Patterns

Type-Safe Routes with Code Generation

# pubspec.yaml
dependencies:
  go_router: ^14.0.0

dev_dependencies:
  build_runner: ^2.4.0
  go_router_builder: ^2.4.0
// lib/router/routes.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

part 'routes.g.dart';

// Type-safe route definitions
@TypedGoRoute(path: '/')
class HomeRoute extends GoRouteData {
  const HomeRoute();
  
  @override
  Widget build(BuildContext context, GoRouterState state) {
    return const HomeScreen();
  }
}

@TypedGoRoute(
  path: '/products/:productId',
)
class ProductRoute extends GoRouteData {
  final String productId;
  final String? highlight;  // Query parameter
  
  const ProductRoute({
    required this.productId,
    this.highlight,
  });
  
  @override
  Widget build(BuildContext context, GoRouterState state) {
    return ProductDetailsScreen(
      productId: productId,
      highlight: highlight == 'true',
    );
  }
}

@TypedGoRoute(path: '/checkout')
class CheckoutRoute extends GoRouteData {
  final Cart $extra;  // Extra data from navigation
  
  const CheckoutRoute({required this.$extra});
  
  @override
  Widget build(BuildContext context, GoRouterState state) {
    return CheckoutScreen(cart: $extra);
  }
}

// Usage with type safety
void navigateTypeSafe(BuildContext context) {
  // Compile-time checked route parameters
  const ProductRoute(productId: '123', highlight: 'true').go(context);
  
  // Extra data with proper typing
  CheckoutRoute($extra: myCart).push(context);
}

Integration with Riverpod

// lib/router/router_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

// Auth state provider
final authStateProvider = StateNotifierProvider((ref) {
  return AuthNotifier();
});

// Router provider that reacts to auth changes
final routerProvider = Provider((ref) {
  final authState = ref.watch(authStateProvider);
  
  return GoRouter(
    initialLocation: '/',
    redirect: (context, state) {
      final isAuthenticated = authState.isAuthenticated;
      final isAuthRoute = state.matchedLocation == '/login' ||
                          state.matchedLocation == '/register';
      
      if (!isAuthenticated && !isAuthRoute) {
        return '/login?redirect=${state.matchedLocation}';
      }
      
      if (isAuthenticated && isAuthRoute) {
        final redirect = state.uri.queryParameters['redirect'];
        return redirect ?? '/';
      }
      
      return null;
    },
    routes: [
      // ... routes
    ],
  );
});

// Main app with Riverpod
class MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final router = ref.watch(routerProvider);
    
    return MaterialApp.router(
      routerConfig: router,
    );
  }
}

Feature Comparison

Feature Navigation 2.0 GoRouter
Setup complexity High (boilerplate) Low (declarative config)
Learning curve Steep Gentle
Deep linking Manual implementation Built-in
Redirects/Guards Manual implementation Built-in redirect
Nested routes Complex Simple nested GoRoutes
Shell routes Manual Pages stack ShellRoute widget
Type safety Manual go_router_builder
Official support Core Flutter Flutter team maintained
Web URL sync Manual parser Automatic
State management Full control refreshListenable

When to Use Each Approach

Use Navigation 2.0 Directly When:

  • You need complete control over route transitions and animations
  • You’re building a large, complex Flutter web app with custom URL schemes
  • You need to implement very custom navigation flows (wizard-style, conditional branching)
  • You’re creating a navigation package or library

Use GoRouter When:

  • You want a clean and quick setup with minimal boilerplate
  • You’re building mobile or hybrid (mobile + web) applications
  • You need deep linking, redirects, and guards out of the box
  • You want official Flutter team support and regular updates
  • You’re working on small to large apps with standard navigation patterns

Common Mistakes to Avoid

Mixing go() and push() incorrectly: Use go() for declarative navigation (changes the entire path), and push() for imperative navigation (adds to stack). Mixing them causes unexpected back behavior.

Forgetting to handle deep links in redirect: When implementing authentication redirects, always preserve the original destination so users land where they intended after login.

Not using path parameters for IDs: Use /products/:id instead of query parameters for resource identifiers. This gives you proper deep linking and cleaner URLs.

Overcomplicating simple navigation: For apps with just a few screens and no deep linking needs, even Navigator 1.0 (Navigator.push) might be sufficient. Don’t over-engineer.

Ignoring route names: Always define route names for easier refactoring and debugging. context.goNamed('product-details') is more maintainable than hardcoded paths.

Conclusion

If you’re building a large, custom web experience and want full control over every navigation detail, Navigation 2.0 is powerful—but be ready for significant boilerplate and complexity.

For most Flutter developers, especially those building mobile-focused or hybrid apps, GoRouter is the better choice. It’s cleaner, easier to maintain, and officially supported by the Flutter team. It handles deep linking, authentication guards, nested routes, and shell navigation with minimal configuration.

Recommendation: Start with GoRouter. If you ever hit its limitations (rare), you can migrate specific flows to custom Navigation 2.0 implementations.

For more on structuring your Flutter app, check out Flutter clean architecture patterns. For official documentation, see the Flutter navigation guide and GoRouter on pub.dev.

Leave a Comment