← Articles

Flutter navigation with go_router: complete guide

By Ann Tech · 15 May 2025

Navigation is one of the first things you design in a Flutter app and one of the hardest to change later. go_router has become the standard choice because it handles deep links, URL reflection, redirects, and nested navigation in a way that Flutter's original Navigator 2.0 API left as an exercise for the reader.

Installation

dependencies:
  go_router: ^14.0.0

Basic route definition

import 'package:go_router/go_router.dart';

final router = GoRouter(
  debugLogDiagnostics: true, // Remove in production
  initialLocation: '/home',
  routes: [
    GoRoute(
      path: '/home',
      name: 'home',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/orders',
      name: 'orders',
      builder: (context, state) => const OrdersScreen(),
      routes: [
        GoRoute(
          path: ':id',
          name: 'order-detail',
          builder: (context, state) => OrderDetailScreen(
            id: state.pathParameters['id']!,
          ),
        ),
      ],
    ),
    GoRoute(
      path: '/profile',
      name: 'profile',
      builder: (context, state) => const ProfileScreen(),
    ),
    GoRoute(
      path: '/login',
      name: 'login',
      builder: (context, state) => const LoginScreen(),
    ),
  ],
);

Wire it into MaterialApp:

MaterialApp.router(
  routerConfig: router,
)
// Navigate to a named route (replaces current stack entry)
context.go('/home');
context.goNamed('home');

// Push (adds to stack, back button returns here)
context.push('/orders/42');
context.pushNamed('order-detail', pathParameters: {'id': '42'});

// Pop
context.pop();
if (context.canPop()) context.pop();

// Pass query parameters
context.go('/orders?status=pending');
// In the builder: state.uri.queryParameters['status']

// Pass extra data (not URL-visible)
context.push('/order-detail', extra: orderObject);
// In the builder: state.extra as Order

Redirects for auth guards

final router = GoRouter(
  redirect: (context, state) {
    final auth = context.read<AuthNotifier>();
    final loggedIn = auth.isLoggedIn;
    final loggingIn = state.matchedLocation == '/login';

    if (!loggedIn && !loggingIn) return '/login';
    if (loggedIn && loggingIn) return '/home';
    return null; // No redirect needed
  },
  refreshListenable: context.read<AuthNotifier>(), // Re-run redirect when auth changes
  routes: [...],
);

When AuthNotifier changes (user logs in/out), go_router re-evaluates the redirect and navigates automatically.

Nested navigation with ShellRoute

For a BottomNavigationBar where each tab has its own stack:

final router = GoRouter(
  routes: [
    ShellRoute(
      builder: (context, state, child) => AppShell(child: child),
      routes: [
        GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
        GoRoute(
          path: '/orders',
          builder: (_, __) => const OrdersScreen(),
          routes: [
            GoRoute(
              path: ':id',
              builder: (_, state) => OrderDetailScreen(
                id: state.pathParameters['id']!,
              ),
            ),
          ],
        ),
        GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
      ],
    ),
  ],
);

StatefulShellRoute for persistent tab state

StatefulShellRoute keeps each tab's state alive when switching:

StatefulShellRoute.indexedStack(
  builder: (context, state, shell) => AppShell(shell: shell),
  branches: [
    StatefulShellBranch(
      routes: [GoRoute(path: '/home', builder: (_, __) => const HomeScreen())],
    ),
    StatefulShellBranch(
      routes: [
        GoRoute(
          path: '/orders',
          builder: (_, __) => const OrdersScreen(),
          routes: [
            GoRoute(
              path: ':id',
              builder: (_, state) => OrderDetailScreen(
                id: state.pathParameters['id']!,
              ),
            ),
          ],
        ),
      ],
    ),
    StatefulShellBranch(
      routes: [GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen())],
    ),
  ],
)

Custom transitions

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

Error screen

GoRouter(
  errorBuilder: (context, state) => ErrorScreen(
    error: state.error,
  ),
)

Type-safe routes (go_router_builder)

For large apps, go_router_builder generates type-safe route classes from annotations:

dev_dependencies:
  go_router_builder: ^2.0.0
  build_runner: ^2.4.0
@TypedGoRoute<HomeRoute>(path: '/home')
class HomeRoute extends GoRouteData {
  const HomeRoute();
  @override
  Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}

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

  @override
  Widget build(BuildContext context, GoRouterState state) =>
      OrderDetailScreen(id: id);
}

Navigate with:

const HomeRoute().go(context);
OrderDetailRoute(id: '42').push(context);

Compiler catches missing parameters, wrong types, and unknown routes at build time.

go_router handles deep links automatically on both platforms. For Android, declare intent filters:

<!-- AndroidManifest.xml -->
<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="example.com" />
</intent-filter>

For iOS, add associated domains in Xcode: applinks:example.com.

Common pitfalls

Using Navigator.push inside a go_router app. Mixing imperative and declarative navigation creates an inconsistent URL and breaks the back button. Stick to context.go/context.push everywhere.

Losing extra data across app restarts. state.extra doesn't survive hot restart or deep link navigation (it's in-memory only). If you need persistent data, encode it in the URL or query parameters.

Redirect loops. If your redirect condition never resolves (always returns a non-null path), the app crashes with a stack overflow. Always ensure there's a terminal condition.

Missing GoRouter.of(context) inside dialogs. Dialogs are shown on the root overlay, which may be above the MaterialApp.router. Use Navigator.of(context, rootNavigator: true) or capture the router before showing the dialog.

Sign in to like, dislike, or report.

Flutter navigation with go_router: complete guide — ANN Tech