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,
)
Navigation
// 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.
Deep link handling
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.