Navigation #
Navigation is the backbone of every multi-page app. Flutter provides two navigation systems: Navigator 1.0, which is imperative and has been around for a long time, and Navigator 2.0, which is declarative and URL-aware — the foundation of GoRouter. Understanding both lets you choose the right one for your app’s needs.
Navigator 1.0 — Imperative Navigation #
Navigator 1.0 works like a stack of cards — you push a new page on top of the stack and pop to go back:
// Push a new page (can go back to the previous page)
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DetailScreen()),
);
// Push and replace the current page (cannot go back)
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
// Push and remove all previous pages (for logout/onboarding completion)
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const HomeScreen()),
(route) => false, // false = remove all
);
// Go back to the previous page
Navigator.of(context).pop();
// Go back and return data
Navigator.of(context).pop('returned data');
// Check whether you can pop (there's a page below)
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
Sending and Receiving Data #
// Send data when pushing
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => EditScreen(initialValue: 'initial value'),
),
);
// result contains the value popped from EditScreen (can be null if back button)
if (result != null) {
setState(() => _value = result);
}
// In EditScreen: return data when popping
ElevatedButton(
onPressed: () => Navigator.of(context).pop('new value'),
child: const Text('Save'),
)
Named Routes — Navigator 1.0 #
// Define routes in MaterialApp
MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => const HomeScreen(),
'/detail': (context) => const DetailScreen(),
'/settings': (context) => const SettingsScreen(),
},
)
// Navigate by name
Navigator.of(context).pushNamed('/detail');
// Send arguments with named routes
Navigator.of(context).pushNamed('/detail', arguments: product);
// Receive arguments at the destination page
final product = ModalRoute.of(context)!.settings.arguments as Product;
Navigator 1.0 named routes are no longer recommended for new apps. Their usage has limitations in supporting deep linking and the web. For new apps, use GoRouter, discussed below.
Custom Transitions #
// PageRouteBuilder for customized transitions
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const DetailScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Slide from the right
const begin = Offset(1.0, 0.0);
const end = Offset.zero;
final tween = Tween(begin: begin, end: end)
.chain(CurveTween(curve: Curves.easeInOut));
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
transitionDuration: const Duration(milliseconds: 300),
),
);
Navigator 2.0 and GoRouter #
Navigator 1.0 is imperative — you command what should happen. This doesn’t fit URL-based navigation (web, deep links) because navigation state isn’t represented as a URL.
Navigator 2.0 introduces a declarative model where navigation state (including the URL) determines which page is displayed. GoRouter, officially supported by the Flutter team, simplifies Navigator 2.0 into an intuitive API.
GoRouter Setup #
# pubspec.yaml
dependencies:
go_router: ^14.0.0
import 'package:go_router/go_router.dart';
// Define the router -- make it global or a provider
// GoRouter instances must be declared as a global variable so
// they aren't rebuilt during hot reload
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/products',
builder: (context, state) => const ProductListScreen(),
),
GoRoute(
path: '/products/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductDetailScreen(productId: id);
},
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
],
// Error page for routes not found
errorBuilder: (context, state) => const NotFoundScreen(),
);
// Use MaterialApp.router
void main() {
runApp(MaterialApp.router(routerConfig: router));
}
Navigating with GoRouter #
// context.go() -- navigate and REPLACE history (cannot go back)
context.go('/products');
context.go('/products/123');
// context.push() -- navigate and ADD to the stack (can go back)
context.push('/settings');
// context.pop() -- go back to the previous page
context.pop();
// context.pushReplacement() -- replace the current page
context.pushReplacement('/login');
// context.goNamed() -- navigate by route name
context.goNamed('productDetail', pathParameters: {'id': '123'});
// context.pushNamed() -- push by route name
context.pushNamed('settings');
go() vs push() — When to Choose? #
To understand how go and push manipulate the route stack differently, we can refer to the navigation architecture visualization below:
flowchart TD
subgraph context.go
GoStart["context.go('/home/detail/123')"] --> GoResolve["Match with Route Tree Declaration"]
GoResolve --> GoState["Reconstruct New Navigation Stack"]
GoState --> GoStack["Stack: [/home, /home/detail/123]"]
end
subgraph context.push
PushStart["context.push('/home/detail/123')"] --> PushOverlay["Add New Page on Top of the Current Stack"]
PushOverlay --> PushStack["Current Stack + /home/detail/123"]
endcontext.go('/page'):
✓ Declarative navigation -- like changing the browser URL
✓ Does not add to the back stack
✓ For tab navigation, bottom nav, or redirects
✗ Users cannot "go back" to the previous page
context.push('/page'):
✓ Stack navigation -- adds to history
✓ Users can go back
✓ For modal navigation, detail screens, forms
Path Parameters and Query Parameters #
// Path parameters -- dynamic parts of the URL (:name)
GoRoute(
path: '/products/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductDetailScreen(productId: id);
},
),
// Navigate to a path with parameters
context.go('/products/abc123');
// Query parameters -- ?key=value in the URL
GoRoute(
path: '/products',
builder: (context, state) {
final category = state.uri.queryParameters['category'];
final sort = state.uri.queryParameters['sort'] ?? 'newest';
return ProductListScreen(category: category, sort: sort);
},
),
// Navigate with query parameters
context.go('/products?category=electronics&sort=price');
// Path + query together
GoRoute(
path: '/products/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
final tab = state.uri.queryParameters['tab'] ?? 'description';
return ProductDetailScreen(productId: id, initialTab: tab);
},
),
context.go('/products/abc123?tab=reviews');
Passing Complex Objects (Extra) #
// For objects that can't be encoded into a URL, use 'extra'
context.go('/products/detail', extra: productObject);
// Receive at the destination page
GoRoute(
path: '/products/detail',
builder: (context, state) {
final product = state.extra as Product;
return ProductDetailScreen(product: product);
},
),
extra doesn’t support deep linking — Dart objects can’t be serialized into a URL. For pages that need deep linking, send the ID via a path parameter and fetch the data at the destination page.Named Routes in GoRouter #
final router = GoRouter(
routes: [
GoRoute(
name: 'home', // route name
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
name: 'productDetail',
path: '/products/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductDetailScreen(productId: id);
},
),
],
);
// Navigate by name
context.goNamed('productDetail', pathParameters: {'id': '123'});
context.goNamed(
'productDetail',
pathParameters: {'id': '123'},
queryParameters: {'tab': 'reviews'},
);
Redirect and Route Guards #
GoRouter supports state-based redirect — very useful for auth guards:
final router = GoRouter(
initialLocation: '/',
// Global redirect -- called on every navigation
redirect: (context, state) {
final isLoggedIn = authService.isLoggedIn;
final isLoginRoute = state.matchedLocation == '/login';
// Not logged in and not on the login page --> redirect to login
if (!isLoggedIn && !isLoginRoute) return '/login';
// Logged in but on the login page --> redirect to home
if (isLoggedIn && isLoginRoute) return '/';
// No redirect needed
return null;
},
routes: [
GoRoute(path: '/', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(
path: '/admin',
// Per-route redirect
redirect: (context, state) {
if (!authService.isAdmin) return '/';
return null;
},
builder: (_, __) => const AdminScreen(),
),
],
)
Reactive Redirect with refreshListenable #
// GoRouter automatically re-evaluates redirects when the notifier changes
GoRouter(
refreshListenable: authService, // ChangeNotifier
redirect: (context, state) {
if (!authService.isLoggedIn) return '/login';
return null;
},
routes: [...],
)
// authService.notifyListeners() --> GoRouter automatically checks the redirect again
class AuthService extends ChangeNotifier {
bool _isLoggedIn = false;
bool get isLoggedIn => _isLoggedIn;
void login() {
_isLoggedIn = true;
notifyListeners(); // the router automatically re-evaluates and navigates to '/'
}
void logout() {
_isLoggedIn = false;
notifyListeners(); // the router automatically redirects to '/login'
}
}
Nested Routes (Sub-Routes) #
GoRoute(
path: '/products',
builder: (context, state) => const ProductListScreen(),
routes: [
// Sub-route: /products/:id
GoRoute(
path: ':id', // relative path -- no / needed
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductDetailScreen(productId: id);
},
routes: [
// Sub-sub-route: /products/:id/reviews
GoRoute(
path: 'reviews',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ReviewsScreen(productId: id);
},
),
],
),
],
),
ShellRoute and StatefulShellRoute — Tab Navigation #
ShellRoute enables a persistent UI shell (like a BottomNavigationBar) that stays visible when switching between tabs. StatefulShellRoute preserves each tab’s state:
final router = GoRouter(
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
// Persistent shell UI -- the BottomNavigationBar stays
return ScaffoldWithNavBar(navigationShell: navigationShell);
},
branches: [
// Branch 1: Home
StatefulShellBranch(
routes: [
GoRoute(
path: '/home',
builder: (_, __) => const HomeScreen(),
routes: [
GoRoute(
path: 'detail/:id',
builder: (context, state) => DetailScreen(
id: state.pathParameters['id']!,
),
),
],
),
],
),
// Branch 2: Explore
StatefulShellBranch(
routes: [
GoRoute(
path: '/explore',
builder: (_, __) => const ExploreScreen(),
),
],
),
// Branch 3: Profile
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
builder: (_, __) => const ProfileScreen(),
),
],
),
],
),
],
);
// Shell widget with a BottomNavigationBar
class ScaffoldWithNavBar extends StatelessWidget {
final StatefulNavigationShell navigationShell;
const ScaffoldWithNavBar({super.key, required this.navigationShell});
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell, // the active tab content
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (index) {
// Switch tabs -- the old tab's state is preserved
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
},
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.explore), label: 'Explore'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
Deep Linking #
Flutter supports deep linking on iOS, Android, and the web. Opening a URL displays the corresponding page in your app. GoRouter automatically handles deep links as long as the route is defined correctly.
// GoRouter handles deep links automatically
// URL: myapp://products/123 --> ProductDetailScreen(productId: '123')
GoRoute(
path: '/products/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductDetailScreen(productId: id);
},
),
// Android configuration (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="app.example.com" />
// </intent-filter>
Summary #
- Navigator 1.0 works like a stack:
pushadds a page,popremoves it. Use it for simple navigation that doesn’t need deep linking.- Navigator 1.0 named routes are no longer recommended — use GoRouter for new apps.
- GoRouter is Flutter’s official router based on Navigator 2.0 — declarative, URL-aware, supports deep linking, redirects, and nested navigation.
- Use
context.go()for navigation that replaces the URL (tabs, redirects) andcontext.push()for stack navigation that can be backed out of.- Path parameters (
:id) for data that must be in the URL. Query parameters (?key=value) for optional data.extrafor Dart objects that don’t need to be URL-encoded (but don’t support deep links).redirectandrefreshListenableenable state-based route guards — for example, auth checks that automatically redirect when the login status changes.StatefulShellRoute.indexedStackfor a BottomNavigationBar with preserved state per tab — each branch has its own Navigator and history.- GoRouter handles deep linking automatically as long as routes are defined correctly and the platform is configured with the right intent filters.