GoRouter
Declarative Routing with Path Parameters and Redirects
Open interactive version (quiz + challenge)Real-world analogy
What is it?
GoRouter is a declarative routing package for Flutter that maps URL paths to screens. It supports path parameters (/user/:id), query parameters, redirects for auth guards, ShellRoute for persistent layouts like bottom navigation, nested routes, and named routes. It replaces Navigator's imperative push/pop with a URL-based declarative approach that supports deep linking and web URLs.
Real-world relevance
In team_mvp_kit, GoRouter is the sole navigation solution. The router configuration lives in a single file with all routes defined declaratively. A redirect guard checks authentication status on every navigation. ShellRoute wraps the main tabs with a persistent BottomNavigationBar. Path parameters pass IDs to detail screens. This pattern scales cleanly from 5 screens to 50+.
Key points
- GoRouter Setup — GoRouter is configured as a single object with a list of GoRoute definitions. Each GoRoute maps a URL path to a widget builder. Pass the GoRouter instance to MaterialApp.router's routerConfig. This replaces MaterialApp's home, routes, and onGenerateRoute with a single declarative configuration.
- Path Parameters — Path parameters are dynamic segments in a URL marked with a colon: '/user/:id'. GoRouter extracts the value from the URL and passes it through state.pathParameters. Use them for detail screens where the ID comes from the URL, like '/product/42' or '/user/abc123'.
- Query Parameters — Query parameters are key-value pairs after the ? in a URL: '/search?q=flutter&sort=date'. Access them via state.uri.queryParameters. Use them for optional filters, search queries, or pagination that do not define the route but modify the screen's content.
- context.go vs context.push — context.go navigates to a path by replacing the current navigation stack — like typing a URL in a browser. context.push adds the screen on top of the stack — like clicking a link. Use go for top-level navigation (tab switches, deep links) and push for drill-down navigation (list to detail).
- Redirect — GoRouter's redirect callback runs before every navigation. Return a new path to redirect, or null to allow the navigation. Use it for authentication guards: if the user is not logged in, redirect '/home' to '/login'. The redirect function receives GoRouterState with the target location.
- ShellRoute — ShellRoute wraps child routes in a shared layout — typically a Scaffold with a BottomNavigationBar. The shell persists while child routes change, so the bottom nav stays visible across tabs. This is how team_mvp_kit implements its main tab navigation with persistent bottom bar.
- Nested Routes (Sub-Routes) — GoRoute can have child routes using the routes property. Child route paths are appended to the parent path. For example, a parent '/user' with a child '/:id' creates '/user/:id'. Nested routes help organize related screens and create logical URL hierarchies.
- Named Routes in GoRouter — Assign a name to each GoRoute for type-safe navigation. Use context.goNamed('routeName', pathParameters: {...}) instead of hardcoding path strings. This prevents typos and makes route changes easier — update the path in one place without changing every navigation call.
Code example
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// Authentication state (simplified)
class AuthService {
bool isLoggedIn = false;
}
final authService = AuthService();
// Router configuration
final goRouter = GoRouter(
initialLocation: '/login',
redirect: (context, state) {
final loggedIn = authService.isLoggedIn;
final loggingIn = state.matchedLocation == '/login';
if (!loggedIn && !loggingIn) return '/login';
if (loggedIn && loggingIn) return '/home';
return null;
},
routes: [
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
ShellRoute(
builder: (context, state, child) {
return MainShell(child: child);
},
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
GoRoute(
name: 'userDetail',
path: '/user/:userId',
builder: (context, state) {
final id = state.pathParameters['userId']!;
return UserDetailScreen(userId: id);
},
),
],
),
],
);
// Main shell with bottom navigation
class MainShell extends StatelessWidget {
final Widget child;
const MainShell({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: _calculateIndex(context),
onDestinationSelected: (index) {
switch (index) {
case 0:
context.go('/home');
case 1:
context.go('/profile');
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.person_outlined),
selectedIcon: Icon(Icons.person),
label: 'Profile',
),
],
),
);
}
int _calculateIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith('/profile')) return 1;
return 0;
}
}
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Center(
child: ElevatedButton(
onPressed: () {
authService.isLoggedIn = true;
context.go('/home');
},
child: const Text('Login'),
),
),
);
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Center(
child: ElevatedButton(
onPressed: () {
context.push('/user/42');
},
child: const Text('View User 42'),
),
),
);
}
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: Center(
child: ElevatedButton(
onPressed: () {
authService.isLoggedIn = false;
context.go('/login');
},
child: const Text('Logout'),
),
),
);
}
}
class UserDetailScreen extends StatelessWidget {
final String userId;
const UserDetailScreen({super.key, required this.userId});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('User $userId')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('User Detail: $userId',
style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.pop(),
child: const Text('Go Back'),
),
],
),
),
);
}
}Line-by-line walkthrough
- 1. Import the Material package
- 2. Import the go_router package
- 3.
- 4. Simple AuthService class to track login status
- 5. Boolean flag for logged-in state
- 6.
- 7. Create a global instance of AuthService
- 8.
- 9. Define the GoRouter configuration
- 10. Start at the login screen
- 11. Redirect callback runs before every navigation
- 12. Check if the user is logged in
- 13. Check if they are heading to the login page
- 14. If not logged in and not going to login, redirect to login
- 15. If logged in but going to login, redirect to home
- 16. Otherwise allow the navigation (return null)
- 17.
- 18.
- 19. Define the login route outside the shell
- 20.
- 21.
- 22. ShellRoute wraps child routes with a shared layout
- 23. Builder provides the child widget for the current tab
- 24. MainShell provides persistent bottom navigation
- 25.
- 26. Home tab route at /home
- 27.
- 28.
- 29. Profile tab route at /profile
- 30.
- 31.
- 32. User detail route with path parameter
- 33. Named route for type-safe navigation
- 34. Extract userId from path parameters
- 35. Pass userId to the detail screen
- 36.
- 37.
- 38.
- 39.
- 40.
- 41. MainShell widget wraps content with bottom navigation
- 42. Accept the child widget that changes per route
- 43.
- 44. Scaffold with the child as body
- 45. NavigationBar for bottom tabs
- 46. Calculate selected index from current path
- 47. Handle tab selection with context.go
- 48. go to /home for first tab
- 49. go to /profile for second tab
- 50.
- 51. Home destination with outlined and filled icons
- 52.
- 53.
- 54.
- 55. Profile destination
- 56.
- 57.
- 58.
- 59.
- 60. Helper method to determine selected tab index
- 61. Get the current matched location from GoRouter
- 62. If path starts with /profile, select index 1
- 63. Default to index 0 (home)
- 64.
- 65.
- 66. LoginScreen with login button
- 67. On press: set logged in to true and go to /home
- 68. GoRouter redirect will allow this since user is now logged in
- 69.
- 70.
- 71.
- 72. HomeScreen with a button to view user detail
- 73. context.push adds user detail ON TOP of the home tab
- 74. Uses push (not go) so user can press back to return
- 75.
- 76.
- 77.
- 78. ProfileScreen with a logout button
- 79. On press: set logged in to false and go to /login
- 80. GoRouter redirect handles clearing the session
- 81.
- 82.
- 83.
- 84. UserDetailScreen receives userId from the route
- 85. Display the user ID
- 86. context.pop goes back to the previous screen
- 87.
- 88.
Spot the bug
GoRouter(
routes: [
GoRoute(
path: 'home',
builder: (context, state) => HomeScreen(),
),
GoRoute(
path: 'user/:id',
builder: (context, state) {
return UserScreen(id: state.pathParameters['userId']!);
},
),
],
)Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- GoRouter Package (pub.dev)
- GoRouter Official Documentation (Flutter Official)
- Navigation and Routing Overview (Flutter Official)