Navigation, routing and deep links with go_router

Declare routes in one place, pass typed arguments, build nested shells with bottom navigation, handle deep links, and control the back button.

A route table

final router = GoRouter(
  initialLocation: '/tasks',
  routes: [
    ShellRoute(
      builder: (context, state, child) => ScaffoldWithNavBar(child: child),
      routes: [
        GoRoute(path: '/tasks', builder: (_, __) => const TaskListPage()),
        GoRoute(path: '/settings', builder: (_, __) => const SettingsPage()),
        GoRoute(
          path: '/tasks/:id',
          name: 'task',
          builder: (context, state) {
            final id = state.pathParameters['id']!;
            final from = state.uri.queryParameters['from'];
            return TaskDetailPage(id: id, from: from);
          },
        ),
      ],
    ),
  ],
  errorBuilder: (context, state) => NotFoundPage(location: state.uri.toString()),
);

// navigate with a name and typed parameters
context.goNamed('task', pathParameters: {'id': task.id});
  • go replaces the location, push adds to the stack. Using go for a detail screen loses the back button.
  • Path parameters are part of the URL and identify the resource; query parameters are optional modifiers such as filters.
  • ShellRoute keeps a shared scaffold — bottom bar, rail — alive across child routes.
  • errorBuilder catches unmatched locations, which is what a bad deep link hits.
final router = GoRouter(
  initialLocation: '/tasks',
  refreshListenable: auth,          // a ChangeNotifier
  redirect: (context, state) {
    final loggedIn = auth.isSignedIn;
    final atLogin = state.matchedLocation == '/login';

    if (!loggedIn && !atLogin) {
      return '/login?next=${Uri.encodeComponent(state.uri.toString())}';
    }
    if (loggedIn && atLogin) return '/tasks';
    return null;                    // null means "allow"
  },
  routes: [/* ... */],
);
PlatformSetupVerifies
AndroidIntent filter for the host and schemeApp Links with a signed assetlinks file
iOSAssociated domains entitlementapple-app-site-association file
WebNothing extraThe path is the URL

A redirect must be deterministic and must terminate. If two rules can bounce a location back and forth you get an infinite loop that only appears in production builds.

Back handling and nested navigation

class TaskDetailPage extends StatelessWidget {
  const TaskDetailPage({super.key, required this.id});

  final String id;

  @override
  Widget build(BuildContext context) {
    return PopScope(
      canPop: false,
      onPopInvokedWithResult: (didPop, result) async {
        if (didPop) return;
        final discard = await showDialog<bool>(
          context: context,
          builder: (context) => AlertDialog(
            title: const Text('Discard changes?'),
            actions: [
              TextButton(
                onPressed: () => Navigator.of(context).pop(false),
                child: const Text('Keep editing'),
              ),
              FilledButton(
                onPressed: () => Navigator.of(context).pop(true),
                child: const Text('Discard'),
              ),
            ],
          ),
        );
        if (discard == true && context.mounted) {
          context.pop();
        }
      },
      child: TaskEditor(id: id),
    );
  }
}
💡
Give every nested navigator its own Navigator key inside a ShellRoute. Without distinct keys, Flutter cannot tell the tab stacks apart and routes restore to the wrong tab after a restart.

FAQ

When should I use <code>go</code> instead of <code>push</code>?
Use go when the destination should become the current location — a tab switch, a deep link, or a post-login landing. Use push for a screen layered on top that the user expects to back out of.
How do I test routing without a device?
Because routes are declared in plain Dart, you can pump the MaterialApp.router in a widget test, call router.go('/tasks/1'), and assert on the rendered page. No integration test or emulator needed.

State management: setState, Provider, Riverpod and BLoC Next steps: Firebase, web and desktop targets

Last refreshed 2026-09-18.