Flutter Navigation — Routes, Navigation, and Deep Linking
In this tutorial, you will learn about Flutter Navigation. We cover key concepts, practical examples, and best practices to help you master this topic.
Flutter navigation manages screen transitions using the Navigator widget and route objects, supporting imperative push/pop, named routes, declarative routing with GoRouter, and deep linking.
What Will You Learn
- Navigator.push and Navigator.pop for basic navigation
- Named routes for organized routing
- onGenerateRoute for typed route arguments
- GoRouter for declarative, web-friendly routing
- Passing data between screens
- Deep linking and URL-based routing
- Bottom navigation and tab-based navigation
Why It Matters
Navigation is the backbone of multi-screen apps. Flutter's Navigator uses a stack-based model where screens are pushed on and popped off. Understanding how to pass data between screens, handle back navigation with results, and implement deep linking is essential for any app with more than one screen. GoRouter simplifies routing for complex apps and provides web URL support out of the box.
Real-World Use
The DodaTech Flutter app uses GoRouter with ShellRoute for a bottom navigation bar. Each tab has its own navigator stack. Deep linking allows users to open specific course pages from push notifications. The router provides type-safe route definitions with typed arguments.
Learning Path
flowchart LR A[State Management] --> B[Flutter Navigation\nYou are here] B --> C[Flutter Forms] style B fill:#f90,color:#fff
Basic Navigation
Navigator.push adds a route on top of the stack. Navigator.pop removes it:
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Home')),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DetailScreen(itemId: 42),
),
);
},
child: Text('Open Detail'),
),
),
);
}
}
class DetailScreen extends StatelessWidget {
final int itemId;
const DetailScreen({super.key, required this.itemId});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Detail $itemId')),
body: Center(
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
child: Text('Go Back'),
),
),
);
}
}
MaterialPageRoute provides platform-appropriate transitions (slide up on Android, slide right on iOS). Navigator.pop returns to the previous screen.
Returning Data from a Screen
Navigator.pop accepts an optional result:
class SelectionScreen extends StatelessWidget {
const SelectionScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Select an option')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => Navigator.pop(context, 'Option A'),
child: Text('Option A'),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => Navigator.pop(context, 'Option B'),
child: Text('Option B'),
),
],
),
),
);
}
}
class HomeScreen2 extends StatelessWidget {
const HomeScreen2({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Home')),
body: Center(
child: ElevatedButton(
onPressed: () async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => SelectionScreen()),
);
if (result != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Selected: $result')),
);
}
},
child: Text('Choose Option'),
),
),
);
}
}
await Navigator.push<T>() returns the value passed to Navigator.pop. Type parameter T ensures type safety.
Named Routes
Named routes organize routes with string identifiers:
void main() {
runApp(
MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => HomeScreen(),
'/about': (context) => AboutScreen(),
'/settings': (context) => SettingsScreen(),
},
),
);
}
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Home')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => Navigator.pushNamed(context, '/about'),
child: Text('About'),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => Navigator.pushNamed(context, '/settings'),
child: Text('Settings'),
),
],
),
),
);
}
}
Named routes simplify navigation for apps with many routes. However, they do not support typed arguments easily.
onGenerateRoute for Typed Arguments
Generate routes programmatically with typed arguments:
class AppRoutes {
static const String home = '/';
static const String detail = '/detail';
static const String profile = '/profile';
}
Route<dynamic> generateRoute(RouteSettings settings) {
switch (settings.name) {
case AppRoutes.home:
return MaterialPageRoute(builder: (_) => HomeScreen3());
case AppRoutes.detail:
final args = settings.arguments as DetailArguments;
return MaterialPageRoute(
builder: (_) => DetailScreen(itemId: args.itemId),
);
case AppRoutes.profile:
final args = settings.arguments as ProfileArguments;
return MaterialPageRoute(
builder: (_) => ProfileScreen(userId: args.userId),
);
default:
return MaterialPageRoute(
builder: (_) => Scaffold(
body: Center(child: Text('Route not found')),
),
);
}
}
class DetailArguments {
final int itemId;
DetailArguments(this.itemId);
}
class ProfileArguments {
final String userId;
ProfileArguments(this.userId);
}
void main() {
runApp(
MaterialApp(
initialRoute: AppRoutes.home,
onGenerateRoute: generateRoute,
),
);
}
class HomeScreen3 extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Home')),
body: Center(
child: ElevatedButton(
onPressed: () => Navigator.pushNamed(
context,
AppRoutes.detail,
arguments: DetailArguments(42),
),
child: Text('Open Detail'),
),
),
);
}
}
onGenerateRoute provides type safety through typed argument classes. The settings.args can be cast to the expected type.
GoRouter
GoRouter is the recommended router for Flutter, supporting declarative routing and deep linking:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// Route definitions
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => HomeScreen4(),
),
GoRoute(
path: '/details/:itemId',
name: 'details',
builder: (context, state) {
final itemId = int.parse(state.pathParameters['itemId']!);
return DetailScreen(itemId: itemId);
},
),
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => SettingsScreen(),
routes: [
GoRoute(
path: 'profile',
name: 'settings-profile',
builder: (context, state) => ProfileScreen(userId: 'current'),
),
],
),
],
);
void main() {
runApp(MaterialApp.router(
routerConfig: router,
));
}
class HomeScreen4 extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('GoRouter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => context.go('/details/42'),
child: Text('Open Item 42'),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.goNamed('details', pathParameters: {'itemId': '99'}),
child: Text('Open Item 99'),
),
],
),
),
);
}
}
GoRouter uses URL-like paths. Path parameters are extracted with :paramName. context.go() navigates to a path. context.goNamed() navigates using route names with typed parameters. Nested routes create hierarchical URLs.
Bottom Navigation with GoRouter
Implement bottom navigation with StatefulShellRoute:
final shellRouter = GoRouter(
initialLocation: '/home',
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return ScaffoldWithNavBar(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: '/home',
builder: (context, state) => HomeScreen5(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/search',
builder: (context, state) => SearchScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
builder: (context, state) => ProfileScreen(userId: 'user'),
),
],
),
],
),
],
);
class ScaffoldWithNavBar extends StatelessWidget {
final StatefulNavigationShell navigationShell;
const ScaffoldWithNavBar({super.key, required this.navigationShell});
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (index) {
navigationShell.goBranch(index);
},
destinations: [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
StatefulShellRoute.indexedStack preserves each tab's navigation state. Switching tabs preserves the stack within each branch.
Deep Linking
GoRouter handles deep linking and web URLs:
final deepLinkRouter = GoRouter(
initialLocation: '/',
routes: [
GoRoute(path: '/', builder: (_, __) => HomeScreen()),
GoRoute(
path: '/course/:courseId/lesson/:lessonId',
builder: (context, state) {
final courseId = state.pathParameters['courseId']!;
final lessonId = state.pathParameters['lessonId']!;
return LessonScreen(courseId: courseId, lessonId: lessonId);
},
),
],
);
URLs like /course/123/lesson/456 navigate directly to the lesson screen. For mobile deep linking, configure the platform-specific intent filters (AndroidManifest.xml for Android, Info.plist for iOS).
Passing Data with GoRouter
Pass complex data via extra parameter:
// Passing data
context.push('/details', extra: Product(id: 1, name: 'Widget'));
// Receiving data
GoRoute(
path: '/details',
builder: (context, state) {
final product = state.extra as Product;
return DetailScreen(product: product);
},
);
Use extra for complex objects. For simple IDs, use path parameters. Avoid passing large objects through navigation to prevent memory issues.
Common Mistakes
Using Navigator.push for simple tab switching: Use bottom navigation or tabs for persistent sections. Navigator.push adds routes to a stack, which is not the right UX for tabs.
Not handling back navigation properly: Use
WillPopScopeorPopScopeto intercept back navigation and show confirmation dialogs.Forgetting to handle unknown routes: The
unknownRouteparameter in MaterialApp provides a fallback route for 404 errors.Deeply nesting Navigator widgets: Multiple Navigators can cause confusion. Use nested routing with GoRouter's StatefulShellRoute instead.
Hardcoding route strings: Define route paths as constants or in a dedicated routes file to avoid typos and enable Refactoring.
Practice Questions
- How does Navigator.push differ from Navigator.pushNamed?
- What is the purpose of onGenerateRoute?
- How does GoRouter handle path parameters?
- What is the advantage of StatefulShellRoute over separate Navigator widgets?
- Challenge: Build a multi-screen app with GoRouter that has three tabs: Products, Cart, and Profile. Products tab shows a list, tapping an item navigates to a detail screen. Cart shows added items. Profile shows user info. Implement deep linking for product pages.
Mini Project
Build a navigation demo app:
- Use GoRouter with StatefulShellRoute for bottom navigation
- Three tabs: Home (grid of categories), Search (search bar + results), Account (user info)
- Category grid items navigate to a list of products
- Product list items navigate to product detail
- Search uses query parameters in the URL
- Implement back navigation with confirmation
- Handle unknown routes with a 404 screen
FAQ
What is Next
Now that you understand navigation, learn about form handling. Proceed to Flutter Forms for input validation, form keys, and submission. Then explore Flutter Networking for HTTP requests and API integration.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro