Skip to main content
Technology & EngineeringFlutter244 lines

Navigation

GoRouter navigation patterns for declarative, deep-linkable Flutter routing

Quick Summary16 lines
You are an expert in Flutter navigation for building cross-platform apps with Flutter.

## Key Points

- **Use named routes** (`context.goNamed('product-detail', pathParameters: {'id': '42'})`) to avoid hardcoding path strings throughout the app.
- **Keep the router definition in a single file** so the app's route structure is easy to review at a glance.
- **Use `StatefulShellRoute`** when tabs need to preserve their scroll position and local state across switches.
- **Handle unknown routes** with an `errorBuilder` on `GoRouter` to show a proper 404 screen.
- **Integrate with Riverpod** by creating the router inside a provider so it can access auth state for redirects.
- **Using `context.go` when `context.push` is needed**: `go` replaces the entire navigation stack; `push` adds on top. Using `go` for detail screens breaks the back button.
- **Passing non-serializable data via `extra`**: Deep links and web URLs cannot carry `extra` data. Use path or query parameters for anything that must survive a cold start.
- **Forgetting `refreshListenable`**: Without it, the redirect guard will not re-evaluate when auth state changes, and users can get stuck on the login screen after authenticating.
- **Duplicating paths across routes**: GoRouter will throw if two routes match the same URL. Keep paths unique and use nested routes for hierarchies.
- **Blocking redirects with async work**: The `redirect` callback is synchronous. Load auth state eagerly or show a splash screen while it resolves.
skilldb get flutter-skills/NavigationFull skill: 244 lines
Paste into your CLAUDE.md or agent config

Navigation — Flutter

You are an expert in Flutter navigation for building cross-platform apps with Flutter.

Core Philosophy

GoRouter brings URL-based, declarative routing to Flutter, which means navigation state is a function of the current URL path, not a stack of widgets pushed imperatively. This model aligns Flutter navigation with how the web works: every screen has a URL, deep linking is automatic, and the browser's back button (on web) just works. On mobile, this URL-first approach gives you deep linking and state restoration for free, because the navigation state can be serialized and restored from a simple string.

The redirect mechanism is GoRouter's most powerful feature for app-wide navigation logic. Instead of scattering auth checks across every screen, a single redirect function at the router level evaluates on every navigation and decides whether to allow it, redirect to login, or redirect to home. When combined with refreshListenable, the redirect re-evaluates automatically when auth state changes, handling both "user logged out" and "token expired" scenarios without any screen-level logic. This centralizes navigation guards in one place.

Screen components should be navigation-agnostic. A ProductDetailScreen should receive a productId parameter and render the product, without knowing whether it was navigated to via context.go, a deep link, or a tab switch. Navigation logic (deciding what URL to go to, when to push vs. replace) belongs in the router configuration and the calling code, not in the destination screen. This separation means screens can be reused in different navigation contexts without modification.

Anti-Patterns

  • Using context.go when context.push is needed: context.go('/detail/42') replaces the entire navigation stack, meaning the back button returns to the root instead of the previous screen. Use context.push to add a screen on top of the current stack, preserving the back navigation that users expect.

  • Passing non-serializable data via the extra parameter: state.extra cannot survive a cold start from a deep link or a web URL, because URLs can only carry string data. Use path parameters (/products/:id) or query parameters (/search?q=flutter) for data that must work with deep links. Reserve extra only for supplementary data in in-app navigation.

  • Forgetting refreshListenable on the router: Without refreshListenable, the redirect function only evaluates when navigation occurs. If the user's auth token expires while they are on a screen, the redirect does not fire, and the user remains on the authenticated screen until they navigate. Adding the auth notifier as a refreshListenable ensures the redirect re-evaluates whenever auth state changes.

  • Duplicating route paths across the configuration: GoRouter throws a runtime error if two routes match the same URL. This is easy to trigger when nested routes accidentally shadow parent routes or when named routes conflict with path-based routes. Keep paths unique and use nested routes for hierarchical URL structures.

  • Performing async work inside the redirect callback: The redirect function is synchronous. Loading auth state from a database or making a network call inside it will cause an error. Auth state must be loaded eagerly (at app startup) and made available synchronously, or a splash screen should be shown while it loads.

Overview

GoRouter is the recommended declarative routing package for Flutter. It provides URL-based routing, deep linking, redirects, nested navigation with ShellRoute, and integrates cleanly with state management for auth guards and route-based data loading.

Core Concepts

Route Configuration

final router = GoRouter(
  initialLocation: '/',
  debugLogDiagnostics: true,
  routes: [
    GoRoute(
      path: '/',
      name: 'home',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/products/:id',
      name: 'product-detail',
      builder: (context, state) {
        final id = state.pathParameters['id']!;
        return ProductDetailScreen(productId: id);
      },
    ),
    GoRoute(
      path: '/search',
      name: 'search',
      builder: (context, state) {
        final query = state.uri.queryParameters['q'] ?? '';
        return SearchScreen(initialQuery: query);
      },
    ),
  ],
);

ShellRoute for Persistent Navigation

ShellRoute wraps child routes in a shared scaffold (e.g., bottom nav bar) without rebuilding the shell on each navigation.

final router = GoRouter(
  routes: [
    ShellRoute(
      builder: (context, state, child) {
        return ScaffoldWithNavBar(child: child);
      },
      routes: [
        GoRoute(
          path: '/feed',
          builder: (context, state) => const FeedScreen(),
        ),
        GoRoute(
          path: '/explore',
          builder: (context, state) => const ExploreScreen(),
        ),
        GoRoute(
          path: '/profile',
          builder: (context, state) => const ProfileScreen(),
        ),
      ],
    ),
    // Routes outside the shell (no bottom nav)
    GoRoute(
      path: '/login',
      builder: (context, state) => const LoginScreen(),
    ),
  ],
);

StatefulShellRoute for Preserving Tab State

StatefulShellRoute.indexedStack(
  builder: (context, state, navigationShell) {
    return ScaffoldWithNavBar(navigationShell: navigationShell);
  },
  branches: [
    StatefulShellBranch(
      routes: [
        GoRoute(path: '/feed', builder: (_, __) => const FeedScreen()),
      ],
    ),
    StatefulShellBranch(
      routes: [
        GoRoute(path: '/explore', builder: (_, __) => const ExploreScreen()),
      ],
    ),
    StatefulShellBranch(
      routes: [
        GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
      ],
    ),
  ],
)

Redirects and Auth Guards

GoRouter(
  redirect: (context, state) {
    final isLoggedIn = authNotifier.isAuthenticated;
    final isLoggingIn = state.matchedLocation == '/login';

    if (!isLoggedIn && !isLoggingIn) return '/login';
    if (isLoggedIn && isLoggingIn) return '/';
    return null; // no redirect
  },
  refreshListenable: authNotifier, // re-evaluates redirect when auth changes
  routes: [/* ... */],
);

Implementation Patterns

Typed Routes with GoRouterBuilder

// Requires code generation with go_router_builder
@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute extends GoRouteData {
  const HomeRoute();

  @override
  Widget build(BuildContext context, GoRouterState state) {
    return const HomeScreen();
  }
}

@TypedGoRoute<ProductRoute>(path: '/products/:id')
class ProductRoute extends GoRouteData {
  const ProductRoute({required this.id});
  final String id;

  @override
  Widget build(BuildContext context, GoRouterState state) {
    return ProductDetailScreen(productId: id);
  }
}

// Navigate with type safety:
// const ProductRoute(id: '42').go(context);

Passing Data via Extra

GoRoute(
  path: '/checkout',
  builder: (context, state) {
    final cart = state.extra as Cart;
    return CheckoutScreen(cart: cart);
  },
),

// Navigate:
context.go('/checkout', extra: currentCart);

Custom Page Transitions

GoRoute(
  path: '/detail',
  pageBuilder: (context, state) {
    return CustomTransitionPage(
      key: state.pageKey,
      child: const DetailScreen(),
      transitionsBuilder: (context, animation, secondaryAnimation, child) {
        return FadeTransition(opacity: animation, child: child);
      },
    );
  },
),

Nested Routes

GoRoute(
  path: '/settings',
  builder: (context, state) => const SettingsScreen(),
  routes: [
    GoRoute(
      path: 'account',  // resolves to /settings/account
      builder: (context, state) => const AccountSettingsScreen(),
    ),
    GoRoute(
      path: 'notifications',  // resolves to /settings/notifications
      builder: (context, state) => const NotificationSettingsScreen(),
    ),
  ],
),

Best Practices

  • Use named routes (context.goNamed('product-detail', pathParameters: {'id': '42'})) to avoid hardcoding path strings throughout the app.
  • Keep the router definition in a single file so the app's route structure is easy to review at a glance.
  • Use StatefulShellRoute when tabs need to preserve their scroll position and local state across switches.
  • Handle unknown routes with an errorBuilder on GoRouter to show a proper 404 screen.
  • Integrate with Riverpod by creating the router inside a provider so it can access auth state for redirects.

Common Pitfalls

  • Using context.go when context.push is needed: go replaces the entire navigation stack; push adds on top. Using go for detail screens breaks the back button.
  • Passing non-serializable data via extra: Deep links and web URLs cannot carry extra data. Use path or query parameters for anything that must survive a cold start.
  • Forgetting refreshListenable: Without it, the redirect guard will not re-evaluate when auth state changes, and users can get stuck on the login screen after authenticating.
  • Duplicating paths across routes: GoRouter will throw if two routes match the same URL. Keep paths unique and use nested routes for hierarchies.
  • Blocking redirects with async work: The redirect callback is synchronous. Load auth state eagerly or show a splash screen while it resolves.

Install this skill directly: skilldb add flutter-skills

Get CLI access →