Flutter Navigation: Scalable Routing with go_router

13 min readFebruary 9, 2026Updated: Mar 9, 2026
Flutter go_routerFlutter navigationFlutter deep linkFlutter route guardFlutter nested routesgo_router redirectFlutter shell routeFlutter routing best practices

# Flutter Navigation with go_router

As your Flutter app grows beyond a handful of screens, navigation quickly becomes one of the hardest things to manage. Deep links break, auth guards get scattered across widgets, and your route definitions end up duplicated in ways that are painful to refactor. I have been through this cycle more than once, and go_router is the tool that finally brought sanity to my routing layer.

In this article I will walk you through a complete go_router setup for a medium-sized app, cover migration from the older Navigator APIs, and share the mistakes I have learned to avoid.

Why go_router?

Flutter ships with Navigator 1.0 (imperative push/pop) and Navigator 2.0 (declarative, but verbose). go_router sits on top of Navigator 2.0 and gives you:

  • **Declarative route definitions** — every route is visible in one place
  • **Built-in redirect logic** — auth guards, onboarding checks, role-based access
  • **Deep link support out of the box** — Android App Links, iOS Universal Links, and web URLs all work with zero extra wiring
  • **Shell routes** — persistent bottom nav bars, side drawers, or tab layouts without rebuilding the shell on every navigation
  • **Type-safe path parameters and query parameters**
  • **Easy testability** — you can unit-test your redirect logic without spinning up a widget tree
  • Installation

    Add go_router to your `pubspec.yaml`:

    dart
    dependencies:
      go_router: ^class="code-number">14.0.class="code-number">0

    Then run `flutter pub get`.

    Basic Route Configuration

    Here is the simplest possible setup. A `GoRouter` instance defines your routes and is passed to `MaterialApp.router`:

    dart
    import class="code-string">'package:go_router/go_router.dart';
    
    final GoRouter router = GoRouter(
      initialLocation: class="code-string">'/',
      routes: [
        GoRoute(
          path: class="code-string">'/',
          name: class="code-string">'home',
          builder: (context, state) => const HomeScreen(),
        ),
        GoRoute(
          path: class="code-string">'/settings',
          name: class="code-string">'settings',
          builder: (context, state) => const SettingsScreen(),
        ),
        GoRoute(
          path: class="code-string">'/profile/:userId',
          name: class="code-string">'profile',
          builder: (context, state) {
            final userId = state.pathParameters[class="code-string">'userId']!;
            return ProfileScreen(userId: userId);
          },
        ),
      ],
    );
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp.router(
          routerConfig: router,
        );
      }
    }

    Navigation is straightforward:

    dart
    class=class="code-string">"code-comment">// By path
    context.go(class="code-string">'/profile/class="code-number">42');
    
    class=class="code-string">"code-comment">// By name (my preferred approach)
    context.goNamed(class="code-string">'profile', pathParameters: {class="code-string">'userId': class="code-string">'class="code-number">42'});
    
    class=class="code-string">"code-comment">// Push onto the stack instead of replacing
    context.push(class="code-string">'/settings');

    Full Setup for a Medium-Sized App

    In my apps, I structure routes by feature. Each feature module exports its own list of `RouteBase` objects, and the top-level router simply composes them. This keeps files small and makes it obvious who owns which route.

    dart
    class=class="code-string">"code-comment">// lib/routing/app_router.dart
    
    import class="code-string">'package:go_router/go_router.dart';
    import class="code-string">'package:flutter_riverpod/flutter_riverpod.dart';
    import class="code-string">'route_names.dart';
    import class="code-string">'auth_routes.dart';
    import class="code-string">'home_routes.dart';
    import class="code-string">'profile_routes.dart';
    import class="code-string">'admin_routes.dart';
    
    final routerProvider = Provider<GoRouter>((ref) {
      final authState = ref.watch(authStateProvider);
    
      return GoRouter(
        initialLocation: class="code-string">'/',
        debugLogDiagnostics: true,
        refreshListenable: authState,
        redirect: (context, state) {
          final isLoggedIn = authState.isAuthenticated;
          final isOnLoginPage = state.matchedLocation == class="code-string">'/login';
          final isOnOnboarding = state.matchedLocation == class="code-string">'/onboarding';
    
          class=class="code-string">"code-comment">// Not logged in — force to login (unless already there)
          if (!isLoggedIn && !isOnLoginPage) return class="code-string">'/login';
    
          class=class="code-string">"code-comment">// Logged in but hitting login page — redirect to home
          if (isLoggedIn && isOnLoginPage) return class="code-string">'/';
    
          class=class="code-string">"code-comment">// Logged in but onboarding not complete
          if (isLoggedIn && !authState.onboardingComplete && !isOnOnboarding) {
            return class="code-string">'/onboarding';
          }
    
          return null; class=class="code-string">"code-comment">// no redirect
        },
        routes: [
          ...authRoutes,
          ShellRoute(
            builder: (context, state, child) {
              return AppShell(child: child);
            },
            routes: [
              ...homeRoutes,
              ...profileRoutes,
              ...adminRoutes,
            ],
          ),
        ],
        errorBuilder: (context, state) => NotFoundScreen(
          error: state.error,
        ),
      );
    });

    Route Name Constants

    Hardcoding path strings everywhere is the fastest way to introduce typos. I keep all route names in one file:

    dart
    class=class="code-string">"code-comment">// lib/routing/route_names.dart
    
    abstract class RouteNames {
      static const home = class="code-string">'home';
      static const login = class="code-string">'login';
      static const onboarding = class="code-string">'onboarding';
      static const profile = class="code-string">'profile';
      static const profileEdit = class="code-string">'profile-edit';
      static const settings = class="code-string">'settings';
      static const adminDashboard = class="code-string">'admin-dashboard';
      static const adminUsers = class="code-string">'admin-users';
      static const articleDetail = class="code-string">'article-detail';
    }

    Feature-Based Route Files

    dart
    class=class="code-string">"code-comment">// lib/routing/home_routes.dart
    
    import class="code-string">'package:go_router/go_router.dart';
    import class="code-string">'route_names.dart';
    
    final homeRoutes = <RouteBase>[
      GoRoute(
        path: class="code-string">'/',
        name: RouteNames.home,
        builder: (context, state) => const HomeScreen(),
        routes: [
          GoRoute(
            path: class="code-string">'article/:articleId',
            name: RouteNames.articleDetail,
            builder: (context, state) {
              final id = state.pathParameters[class="code-string">'articleId']!;
              return ArticleDetailScreen(articleId: id);
            },
          ),
        ],
      ),
      GoRoute(
        path: class="code-string">'/settings',
        name: RouteNames.settings,
        builder: (context, state) => const SettingsScreen(),
      ),
    ];

    Nested Routes and Shell Routes

    Shell routes are one of go_router's most powerful features. They let you keep a persistent UI element — like a bottom navigation bar — while swapping only the content area.

    dart
    ShellRoute(
      builder: (context, state, child) {
        return ScaffoldWithBottomNav(child: child);
      },
      routes: [
        GoRoute(
          path: class="code-string">'/feed',
          name: class="code-string">'feed',
          builder: (context, state) => const FeedScreen(),
        ),
        GoRoute(
          path: class="code-string">'/search',
          name: class="code-string">'search',
          builder: (context, state) => const SearchScreen(),
        ),
        GoRoute(
          path: class="code-string">'/profile',
          name: class="code-string">'profile',
          builder: (context, state) => const ProfileScreen(),
        ),
      ],
    )

    The `ScaffoldWithBottomNav` widget receives the `child` and places it inside its body. The bottom nav bar stays mounted, preserving its state, while only the content transitions.

    StatefulShellRoute for Independent Navigation Stacks

    If each tab needs its own back stack (think Instagram-style navigation), use `StatefulShellRoute`:

    dart
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) {
        return MainShell(navigationShell: navigationShell);
      },
      branches: [
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: class="code-string">'/feed',
              builder: (context, state) => const FeedScreen(),
              routes: [
                GoRoute(
                  path: class="code-string">'detail/:postId',
                  builder: (context, state) => PostDetailScreen(
                    postId: state.pathParameters[class="code-string">'postId']!,
                  ),
                ),
              ],
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: class="code-string">'/profile',
              builder: (context, state) => const ProfileScreen(),
            ),
          ],
        ),
      ],
    )

    Each branch preserves its own navigation stack. When a user switches tabs and comes back, the previous position is retained.

    Redirect Guards

    Redirects are the cleanest way to handle auth and permission checks. In my experience, keeping all redirect logic in one top-level function is far better than scattering guards across individual routes.

    dart
    redirect: (context, state) {
      final auth = ref.read(authProvider);
      final location = state.matchedLocation;
    
      class=class="code-string">"code-comment">// Public routes that never redirect
      const publicRoutes = [class="code-string">'/login', class="code-string">'/register', class="code-string">'/forgot-password'];
      if (publicRoutes.contains(location)) {
        return auth.isLoggedIn ? class="code-string">'/' : null;
      }
    
      class=class="code-string">"code-comment">// Everything else requires auth
      if (!auth.isLoggedIn) {
        return class="code-string">'/login?redirect=${Uri.encodeComponent(location)}';
      }
    
      class=class="code-string">"code-comment">// Admin routes require admin role
      if (location.startsWith(class="code-string">'/admin') && !auth.isAdmin) {
        return class="code-string">'/';
      }
    
      return null;
    },

    Notice the `redirect` query parameter. After login, you can read it and send the user back where they originally wanted to go:

    dart
    GoRoute(
      path: class="code-string">'/login',
      builder: (context, state) {
        final redirectTo = state.uri.queryParameters[class="code-string">'redirect'] ?? class="code-string">'/';
        return LoginScreen(redirectAfterLogin: redirectTo);
      },
    ),

    Deep Linking Setup

    go_router handles deep links automatically on the routing side, but platform configuration is still required.

    Android — App Links

    Add an intent filter to `android/app/src/main/AndroidManifest.xml`:

    dart
    <activity ...>
      <intent-filter android:autoVerify=class="code-string">"true">
        <action android:name=class="code-string">"android.intent.action.VIEW" />
        <category android:name=class="code-string">"android.intent.category.DEFAULT" />
        <category android:name=class="code-string">"android.intent.category.BROWSABLE" />
        <data
          android:scheme=class="code-string">"https"
          android:host=class="code-string">"yourapp.com"
          android:pathPrefix=class="code-string">"/article" />
      </intent-filter>
    </activity>

    Host a `.well-known/assetlinks.json` file on your domain to verify ownership.

    iOS — Universal Links

    Add an Associated Domains entitlement in Xcode:

    applinks:yourapp.com

    Host a `.well-known/apple-app-site-association` file:

    dart
    {
      class="code-string">"applinks": {
        class="code-string">"apps": [],
        class="code-string">"details": [
          {
            class="code-string">"appID": class="code-string">"TEAMID.com.yourcompany.yourapp",
            class="code-string">"paths": [class="code-string">"/article/*", class="code-string">"/profile/*"]
          }
        ]
      }
    }

    Testing Deep Links Locally

    dart
    class=class="code-string">"code-comment">// Android
    adb shell am start -a android.intent.action.VIEW \
      -d class="code-string">"https:class="code-commentclass="code-string">">//yourapp.com/article/class="code-number">42" com.yourcompany.yourapp
    
    class=class="code-string">"code-comment">// iOS
    xcrun simctl openurl booted class="code-string">"https:class="code-commentclass="code-string">">//yourapp.com/article/class="code-number">42"

    go_router will parse the incoming URL and match it against your defined routes. If `/article/:articleId` exists in your route tree, the user lands directly on `ArticleDetailScreen(articleId: '42')`.

    Migration from Navigator 1.0 / 2.0

    From Navigator 1.0 (Imperative)

    If your codebase uses `Navigator.push`, `Navigator.pushNamed`, and `Navigator.pop`, migration is straightforward:

    | Navigator 1.0 | go_router |

    |---|---|

    | `Navigator.pushNamed(context, '/profile')` | `context.go('/profile')` or `context.push('/profile')` |

    | `Navigator.pop(context)` | `context.pop()` |

    | `Navigator.pushReplacementNamed(context, '/home')` | `context.go('/home')` |

    | `Navigator.pushAndRemoveUntil(...)` | `context.go('/home')` (clears stack) |

    | `onGenerateRoute` | `GoRouter(routes: [...])` |

    Key difference: `context.go()` replaces the entire navigation stack to match the target path. `context.push()` adds on top, similar to the old `Navigator.push`.

    From Navigator 2.0 (RouterDelegate + RouteInformationParser)

    If you already built a custom Router setup, go_router replaces both your `RouterDelegate` and `RouteInformationParser`. You can delete those classes entirely and move your route matching logic into `GoRouter(routes: [...])`.

    Migration Strategy

    I recommend migrating feature by feature rather than all at once:

  • **Set up go_router** with `MaterialApp.router` and define routes for one feature
  • **Keep `Navigator.push`** calls working temporarily — go_router sits on top of Navigator, so old calls still function
  • **Migrate one feature at a time**, replacing `Navigator.pushNamed` calls with `context.goNamed`
  • **Move auth guards** from individual screens into the top-level `redirect`
  • **Remove the old `onGenerateRoute`** once all features are migrated
  • Common Routing Mistakes

    1. Using `go()` When You Mean `push()`

    `context.go('/detail/42')` replaces the stack. If the user presses back, they will not return to the previous screen — they will go to whatever the parent route is. Use `context.push('/detail/42')` when you want a standard back behavior.

    2. Forgetting refreshListenable

    If your redirect depends on auth state, you need to tell go_router to re-evaluate redirects when that state changes:

    dart
    GoRouter(
      refreshListenable: authStateNotifier, class=class="code-string">"code-comment">// must implement Listenable
      redirect: (context, state) { ... },
    )

    Without this, a user who logs out will stay on the protected page until they manually navigate.

    3. Duplicating Route Paths

    Defining the same path in two different places leads to unpredictable matching. In my route structure, every path is defined exactly once, and I enforce this with a simple test:

    dart
    test(class="code-string">'no duplicate route paths', () {
      final paths = <String>[];
      void collectPaths(List<RouteBase> routes, String prefix) {
        for (final route in routes) {
          if (route is GoRoute) {
            final fullPath = class="code-string">'$prefix/${route.path}'.replaceAll(class="code-string">'class=class="code-string">"code-comment">//', class="code-string">'/');
            expect(paths.contains(fullPath), isFalse,
              reason: class="code-string">'Duplicate path: $fullPath');
            paths.add(fullPath);
          }
          if (route is GoRoute) collectPaths(route.routes, route.path);
          if (route is ShellRoute) collectPaths(route.routes, prefix);
        }
      }
      collectPaths(router.configuration.routes, class="code-string">'');
    });

    4. Deep Nesting Without Reason

    Every nested route extends the URL path. `/home/feed/post/42/comments/5/reply` is a sign you should flatten your hierarchy. I try to keep nesting to two levels maximum.

    5. Not Handling the Error Route

    If a user hits a URL that does not match any route, go_router shows a default error page. Always provide your own:

    dart
    GoRouter(
      errorBuilder: (context, state) => const NotFoundScreen(),
    )

    6. Passing Complex Objects via extra

    `state.extra` lets you pass any object during navigation, but it breaks deep links (the object is not serializable to a URL). Pass IDs as path or query parameters instead, and fetch the full object on the destination screen.

    Testing Your Routes

    go_router makes route testing much simpler than raw Navigator 2.0:

    dart
    test(class="code-string">'redirects unauthenticated user to login', () {
      final router = GoRouter(
        initialLocation: class="code-string">'/profile',
        redirect: (context, state) {
          if (!isLoggedIn) return class="code-string">'/login';
          return null;
        },
        routes: [
          GoRoute(path: class="code-string">'/login', builder: (_, __) => const LoginScreen()),
          GoRoute(path: class="code-string">'/profile', builder: (_, __) => const ProfileScreen()),
        ],
      );
    
      class=class="code-string">"code-comment">// After initialization, the router should have redirected
      expect(router.location, class="code-string">'/login');
    });

    Conclusion

    go_router has become my default navigation solution for every Flutter project beyond a simple prototype. The combination of declarative routes, centralized redirects, shell routes, and deep link support covers the vast majority of real-world navigation needs. Structure your routes by feature, keep your redirect logic in one place, prefer named routes over raw paths, and you will save yourself hours of debugging.

    If you are planning a migration from Navigator 1.0 or 2.0, I can help you design a step-by-step transition plan tailored to your app's architecture.

    Related Articles

    Have a Flutter Project?

    I build high-performance Flutter applications for iOS, Android, and web.

    Get in Touch