Flutter

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

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: ^14.0.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 'package:go_router/go_router.dart';

final GoRouter router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      name: 'home',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/settings',
      name: 'settings',
      builder: (context, state) => const SettingsScreen(),
    ),
    GoRoute(
      path: '/profile/:userId',
      name: 'profile',
      builder: (context, state) {
        final userId = state.pathParameters['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
// By path
context.go('/profile/42');

// By name (my preferred approach)
context.goNamed('profile', pathParameters: {'userId': '42'});

// Push onto the stack instead of replacing
context.push('/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
// lib/routing/app_router.dart

import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'route_names.dart';
import 'auth_routes.dart';
import 'home_routes.dart';
import 'profile_routes.dart';
import 'admin_routes.dart';

final routerProvider = Provider<GoRouter>((ref) {
  final authState = ref.watch(authStateProvider);

  return GoRouter(
    initialLocation: '/',
    debugLogDiagnostics: true,
    refreshListenable: authState,
    redirect: (context, state) {
      final isLoggedIn = authState.isAuthenticated;
      final isOnLoginPage = state.matchedLocation == '/login';
      final isOnOnboarding = state.matchedLocation == '/onboarding';

      // Not logged in — force to login (unless already there)
      if (!isLoggedIn && !isOnLoginPage) return '/login';

      // Logged in but hitting login page — redirect to home
      if (isLoggedIn && isOnLoginPage) return '/';

      // Logged in but onboarding not complete
      if (isLoggedIn && !authState.onboardingComplete && !isOnOnboarding) {
        return '/onboarding';
      }

      return null; // 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
// lib/routing/route_names.dart

abstract class RouteNames {
  static const home = 'home';
  static const login = 'login';
  static const onboarding = 'onboarding';
  static const profile = 'profile';
  static const profileEdit = 'profile-edit';
  static const settings = 'settings';
  static const adminDashboard = 'admin-dashboard';
  static const adminUsers = 'admin-users';
  static const articleDetail = 'article-detail';
}

Feature-Based Route Files

dart
// lib/routing/home_routes.dart

import 'package:go_router/go_router.dart';
import 'route_names.dart';

final homeRoutes = <RouteBase>[
  GoRoute(
    path: '/',
    name: RouteNames.home,
    builder: (context, state) => const HomeScreen(),
    routes: [
      GoRoute(
        path: 'article/:articleId',
        name: RouteNames.articleDetail,
        builder: (context, state) {
          final id = state.pathParameters['articleId']!;
          return ArticleDetailScreen(articleId: id);
        },
      ),
    ],
  ),
  GoRoute(
    path: '/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: '/feed',
      name: 'feed',
      builder: (context, state) => const FeedScreen(),
    ),
    GoRoute(
      path: '/search',
      name: 'search',
      builder: (context, state) => const SearchScreen(),
    ),
    GoRoute(
      path: '/profile',
      name: '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: '/feed',
          builder: (context, state) => const FeedScreen(),
          routes: [
            GoRoute(
              path: 'detail/:postId',
              builder: (context, state) => PostDetailScreen(
                postId: state.pathParameters['postId']!,
              ),
            ),
          ],
        ),
      ],
    ),
    StatefulShellBranch(
      routes: [
        GoRoute(
          path: '/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;

  // Public routes that never redirect
  const publicRoutes = ['/login', '/register', '/forgot-password'];
  if (publicRoutes.contains(location)) {
    return auth.isLoggedIn ? '/' : null;
  }

  // Everything else requires auth
  if (!auth.isLoggedIn) {
    return '/login?redirect=${Uri.encodeComponent(location)}';
  }

  // Admin routes require admin role
  if (location.startsWith('/admin') && !auth.isAdmin) {
    return '/';
  }

  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: '/login',
  builder: (context, state) {
    final redirectTo = state.uri.queryParameters['redirect'] ?? '/';
    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="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data
      android:scheme="https"
      android:host="yourapp.com"
      android:pathPrefix="/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
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.yourcompany.yourapp",
        "paths": ["/article/*", "/profile/*"]
      }
    ]
  }
}

Testing Deep Links Locally

dart
// Android
adb shell am start -a android.intent.action.VIEW \
  -d "https://yourapp.com/article/42" com.yourcompany.yourapp

// iOS
xcrun simctl openurl booted "https://yourapp.com/article/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:

  1. Set up go_router with MaterialApp.router and define routes for one feature
  2. Keep `Navigator.push` calls working temporarily — go_router sits on top of Navigator, so old calls still function
  3. Migrate one feature at a time, replacing Navigator.pushNamed calls with context.goNamed
  4. Move auth guards from individual screens into the top-level redirect
  5. 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, // 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('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 = '$prefix/${route.path}'.replaceAll('//', '/');
        expect(paths.contains(fullPath), isFalse,
          reason: '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, '');
});

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('redirects unauthenticated user to login', () {
  final router = GoRouter(
    initialLocation: '/profile',
    redirect: (context, state) {
      if (!isLoggedIn) return '/login';
      return null;
    },
    routes: [
      GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
      GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
    ],
  );

  // After initialization, the router should have redirected
  expect(router.location, '/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