Flutter go_router Navigation Guide: From Basics to Advanced Patterns

A practical guide to Flutter’s go_router: setup, parameters, guards, nested tabs, deep links, transitions, and testing with concise, production-ready code.

ASOasis
7 min read
Flutter go_router Navigation Guide: From Basics to Advanced Patterns

Image used for representation purposes only.

Overview

Flutter’s go_router brings a declarative, URL-first approach to navigation that works consistently across mobile, web, and desktop. It simplifies deep links, nested navigation, guards/redirects, and testing—without fighting the Navigator API directly. This guide walks through practical patterns you can drop into production.

Quick start

  1. Add the package:
flutter pub add go_router
  1. Create a router and wire it into MaterialApp.router:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: _router,
      title: 'go_router demo',
      theme: ThemeData(useMaterial3: true),
    );
  }
}

final _router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      name: 'home',
      builder: (context, state) => const HomeScreen(),
      routes: [
        GoRoute(
          path: 'details/:id',
          name: 'details',
          builder: (context, state) {
            final id = state.pathParameters['id']!;
            final tab = state.uri.queryParameters['tab'];
            final item = state.extra as Item?; // Optional typed payload
            return DetailsScreen(id: id, initialTab: tab, item: item);
          },
        ),
      ],
    ),
    GoRoute(
      path: '/settings',
      name: 'settings',
      builder: (context, state) => const SettingsScreen(),
    ),
  ],
  errorBuilder: (context, state) => ErrorScreen(error: state.error),
);

Key ideas:

  • Use name for routes to avoid hard-coded string paths in your app code.
  • Nested routes let you define children relative to their parent (e.g., details/:id).
  • GoRouterState exposes pathParameters, uri.queryParameters, and extra.
  • context.go('/settings') replaces the current stack with a new location. Best for top-level navigation (e.g., switching tabs or visiting a new root).
  • context.push('/details/42') pushes a new page on top of the stack. Best for drill-in flows.
  • Named navigation avoids manual string interpolation:
// Build the URL from route name + parameters
context.goNamed(
  'details',
  pathParameters: {'id': '42'},
  queryParameters: {'tab': 'info'},
  extra: const Item(id: '42', title: 'Example'),
);

// Return to previous page
context.pop();

Tips:

  • Prefer named APIs (goNamed, pushNamed) for maintainability.
  • Use extra for passing non-serializable objects in-app; use query parameters for shareable/deep-linkable state.

Reading parameters and data

class DetailsScreen extends StatelessWidget {
  const DetailsScreen({super.key, required this.id, this.initialTab, this.item});
  final String id;
  final String? initialTab;
  final Item? item;

  factory DetailsScreen.fromState(GoRouterState state) => DetailsScreen(
        id: state.pathParameters['id']!,
        initialTab: state.uri.queryParameters['tab'],
        item: state.extra as Item?,
      );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Details $id')),
      body: Text('Tab: ${initialTab ?? 'default'} | Item: ${item?.title ?? '-'}'),
    );
  }
}
  • Path parameters are required by definition; check for null only if you have parallel optional routes.
  • Query parameters are strings; parse/validate as needed.
  • Keep parsing close to the page boundary (e.g., via a factory constructor) to keep widgets lean.

Guards and redirects (auth, onboarding, paywalls)

You can enforce navigation rules globally or per-route.

Global redirect with a refresh signal:

final auth = AuthController();

final router = GoRouter(
  refreshListenable: GoRouterRefreshStream(auth.stream),
  redirect: (context, state) {
    final loggedIn = auth.isLoggedIn;
    final loggingIn = state.matchedLocation == '/login';

    if (!loggedIn) return loggingIn ? null : '/login';
    if (loggingIn) return '/';
    return null; // no redirect
  },
  routes: [
    GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
    GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
  ],
);

Per-route redirect (fine-grained guards):

GoRoute(
  path: '/profile',
  name: 'profile',
  redirect: (context, state) => auth.isLoggedIn ? null : '/login',
  builder: (context, state) => const ProfileScreen(),
)

Notes:

  • Return a String path to redirect, or null to allow.
  • Use refreshListenable (e.g., GoRouterRefreshStream, ChangeNotifier) so guards re-evaluate when auth state changes.

Nested navigation and bottom tabs

Two powerful patterns:

  1. ShellRoute: Wraps child routes in a common UI (e.g., shared Scaffold). It does not preserve independent stacks per tab.
  2. StatefulShellRoute.indexedStack: Multiple branches with their own Navigators, preserving state across tab switches.

Bottom navigation with preserved state:

final router = GoRouter(
  routes: [
    StatefulShellRoute.indexedStack(
      branches: [
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/feed',
              name: 'feed',
              builder: (context, state) => const FeedScreen(),
              routes: [
                GoRoute(
                  path: 'post/:id',
                  name: 'post',
                  builder: (context, state) => PostScreen(
                    id: state.pathParameters['id']!,
                  ),
                ),
              ],
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/search',
              name: 'search',
              builder: (context, state) => const SearchScreen(),
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/profile',
              name: 'profile',
              builder: (context, state) => const ProfileScreen(),
            ),
          ],
        ),
      ],
      builder: (context, state, navigationShell) {
        return Scaffold(
          body: navigationShell,
          bottomNavigationBar: NavigationBar(
            selectedIndex: navigationShell.currentIndex,
            onDestinationSelected: (index) => navigationShell.goBranch(index),
            destinations: const [
              NavigationDestination(icon: Icon(Icons.home), label: 'Feed'),
              NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
              NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
            ],
          ),
        );
      },
    ),
  ],
);

Tip: Use navigationShell.goBranch(index, initialLocation: true) to reset a branch to its root when re-selecting the current tab.

Layout shells (shared scaffolds, drawers)

Use ShellRoute to provide persistent UI around child pages without separate stacks:

ShellRoute(
  builder: (context, state, child) => AppScaffold(child: child),
  routes: [
    GoRoute(path: '/inbox', builder: (context, state) => const InboxScreen()),
    GoRoute(path: '/sent', builder: (context, state) => const SentScreen()),
  ],
)

Error pages and unknown routes

  • Provide a friendly 404/unknown route with errorBuilder.
  • From GoRouterState, inspect state.error or display the attempted state.uri.toString().
class ErrorScreen extends StatelessWidget {
  const ErrorScreen({super.key, this.error});
  final Exception? error;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Page not found')),
      body: Center(child: Text(error?.toString() ?? 'The page does not exist.')),
    );
  }
}

Custom transitions and pages

Swap the default transition per route using pageBuilder and CustomTransitionPage:

GoRoute(
  path: '/modal',
  name: 'modal',
  pageBuilder: (context, state) => CustomTransitionPage(
    key: state.pageKey,
    barrierColor: Colors.black54,
    transitionsBuilder: (context, animation, secondary, child) {
      final tween = Tween(begin: const Offset(0, 1), end: Offset.zero)
          .chain(CurveTween(curve: Curves.easeOutCubic));
      return SlideTransition(position: animation.drive(tween), child: child);
    },
    child: const ModalScreen(),
  ),
)

Use NoTransitionPage for instant switches (e.g., between tabs).

  • Deep linking works out of the box when you use MaterialApp.router with GoRouter.
  • On Flutter web, remove the hash from URLs:
import 'package:flutter_web_plugins/url_strategy.dart';

void main() {
  setUrlStrategy(PathUrlStrategy());
  runApp(const MyApp());
}
  • For mobile deep links (Android App Links / iOS Universal Links), configure platform-side intents/associations; go_router will match the incoming path to your route table.

State restoration and the back button

  • go_router integrates with platform back gestures and browser history.
  • context.go replaces history entries; context.push adds them. Choose deliberately to make the back stack feel natural.
  • In nested stacks, the current branch pops first; switching branches preserves each branch’s stack when using StatefulShellRoute.indexedStack.

Testing your routes

Widget test navigation flows without spinning up a full app shell:

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() {
  testWidgets('navigates to details screen', (tester) async {
    final router = GoRouter(
      routes: [
        GoRoute(
          path: '/',
          builder: (context, state) => Builder(
            builder: (context) => TextButton(
              onPressed: () => context.push('/details/1'),
              child: const Text('Open'),
            ),
          ),
        ),
        GoRoute(
          path: '/details/:id',
          builder: (context, state) => Text('Details ${state.pathParameters['id']}'),
        ),
      ],
    );

    await tester.pumpWidget(MaterialApp.router(routerConfig: router));
    await tester.tap(find.text('Open'));
    await tester.pumpAndSettle();
    expect(find.text('Details 1'), findsOneWidget);
  });
}

Tips:

  • Use router.go('/path') in tests to jump directly to a location and assert the rendered UI.
  • For auth flows, inject a fake auth controller and trigger stream updates to verify redirects.

Organization tips for production

  • Centralize routes in a router.dart (or feature-local routers for modular apps).
  • Prefer named routes; co-locate parameter parsing with page constructors.
  • Keep redirects side-effect free—only compute and return a path or null.
  • For large apps, consider typed routing via code generation (e.g., annotations + a builder) to reduce runtime URL mistakes.

Common pitfalls and fixes

  • “Nothing happens on tap”: Ensure you used MaterialApp.router(routerConfig: router) and not the legacy onGenerateRoute APIs.
  • Query parameters missing: Read them from state.uri.queryParameters, not from pathParameters.
  • Back button leaves the app: You likely used go where a push fit better. Replace with push to create a back-stack entry.
  • Tabs lose scroll position: Use StatefulShellRoute.indexedStack instead of ShellRoute to preserve each branch state.
  • Extra is null on hot restart: extra is in-memory only. For shareable state, encode it in the URL path/query.

Conclusion

go_router reduces navigation complexity while embracing Flutter’s multi-platform nature. Start with a clean route table, lean on named navigation, add guards where needed, and use StatefulShellRoute.indexedStack for tabbed apps. With deep links, custom transitions, and solid testability, you’ll ship a navigation experience that feels native everywhere.

Related Posts