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});goreplaces the location,pushadds to the stack. Usinggofor 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.
ShellRoutekeeps a shared scaffold — bottom bar, rail — alive across child routes.errorBuildercatches unmatched locations, which is what a bad deep link hits.
Deep links and redirects
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: [/* ... */],
);| Platform | Setup | Verifies |
|---|---|---|
| Android | Intent filter for the host and scheme | App Links with a signed assetlinks file |
| iOS | Associated domains entitlement | apple-app-site-association file |
| Web | Nothing extra | The 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.Related
State management: setState, Provider, Riverpod and BLoC Next steps: Firebase, web and desktop targets
Last refreshed 2026-09-18.