Navigation and Linking
The Navigator and the route stack, named routes, passing data, transitions, and deep versus dynamic links.
Navigation
Navigation in Flutter is a stack of routes managed by a Navigator widget. Named routes, arguments, and custom transitions are all built on that one idea.
Navigator and Route
At the core of Flutter's navigation system are two key elements:
- Navigator: A widget that manages a stack of
Routeobjects. - Route: An abstraction representing a "screen" or "page" in your app.
The Navigator widget
The Navigator is actually a stateful widget that maintains a stack data structure of routes. This stack follows the LIFO (Last In, First Out) principle, which means:
- The last route added to the stack is the first one removed
- The topmost route in the stack is what the user sees on screen
The Navigator widget is usually created for you automatically when you use MaterialApp or CupertinoApp, and it's accessible through a static method:
Navigator.of(context)Or through a shorthand:
Navigator.push(context, route)The Route class
A Route is an abstract class in Flutter that represents a screen. There are several implementations:
- MaterialPageRoute: Provides a platform-adaptive transition. On Android, it slides in from the bottom, while on iOS, it slides in from the right.
- CupertinoPageRoute: Provides iOS-style transitions.
- PageRouteBuilder: Allows you to create custom route transitions.
The navigation stack
Flutter's navigation implements a stack-based system, where routes are pushed and popped from a stack maintained by the Navigator.
How stack operations work
-
Pushing a Route:
- When
Navigator.push()is called, a new route is created and added to the top of the stack - The Navigator displays the new route with an animation
- The previous route is still in memory but not visible (it's "underneath" the new route)
- When
-
Popping a Route:
- When
Navigator.pop()is called, the topmost route is removed from the stack - The Navigator displays the transition animation as the current route is removed
- The previous route (now at the top of the stack) becomes visible again
- When
Here's what happens internally when Navigator.push() is called:
- The Navigator widget receives the push request
- It calls
createRoute()on the provided Route object to create the actual route - It adds this route to its internal routes list
- It updates its state, which triggers a rebuild
- During the rebuild, it renders the new route with an animation
The role of BuildContext
What BuildContext is in general is covered in widgets-state-and-lifecycle. Navigation cares about
one property of it: the context you pass to Navigator.of(context) decides which navigator you get.
BuildContext is crucial for navigation. It represents the location of a widget in the widget tree and provides access to the nearest Navigator ancestor.
When you call Navigator.of(context), Flutter traverses up the widget tree starting from the given context to find the nearest Navigator widget. This is why you need a valid BuildContext to perform navigation.
Named routes vs direct navigation
Direct navigation (imperative)
When using direct navigation with Navigator.push(), you create a new route instance and provide it directly to the Navigator:
Navigator.push(
context,
MaterialPageRoute(builder: (context) => SecondScreen()),
);Internally, this:
- Creates a new
MaterialPageRoutewith a builder function - When the route is pushed, the builder function is called to create the actual widget
- The route handles the animation and rendering of the new screen
Named routes (declarative)
With named routes, you pre-define your routes in the MaterialApp:
MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => HomeScreen(),
'/details': (context) => DetailScreen(),
},
)When you call Navigator.pushNamed(context, '/details'), Flutter:
- Looks up the route name in the predefined routes map
- Creates a
MaterialPageRoutewith the builder function from the map - Pushes this route onto the Navigator's stack
onGenerateRoute
For more dynamic routing, Flutter provides onGenerateRoute, which gets called when you navigate to a named route that isn't defined in the routes map:
MaterialApp(
onGenerateRoute: (settings) {
// Extract route name
final name = settings.name;
// Extract route arguments
final arguments = settings.arguments;
// Parse and handle the route
if (name == '/product') {
return MaterialPageRoute(
builder: (context) => ProductScreen(id: arguments),
);
}
// Return null to allow Flutter to handle routes it knows about
return null;
},
)This function receives a RouteSettings object containing:
name: The route name that was requestedarguments: Any arguments passed to the route
You can then conditionally create and return a Route object based on the requested route name and arguments.
Passing and returning data
Constructor parameters
The simplest approach is passing data via the constructor:
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DetailScreen(product: product),
),
);Internally, this works because:
- When the route is created, it captures the reference to your data object
- When the route is activated, it creates the destination widget with the data
Named routes with arguments
When using named routes, you can pass data through the arguments parameter:
Navigator.pushNamed(
context,
'/details',
arguments: {'id': 123, 'name': 'Product'},
);To retrieve this data:
final arguments = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;How this works:
- The arguments are stored in the
RouteSettingsobject ModalRoute.of(context)gets the current route for the given context- From that route, you can access the settings and arguments
Returning data from a route
When a screen needs to return data:
// In the calling screen
final result = await Navigator.push(...);
// In the called screen
Navigator.pop(context, resultData);Internally:
Navigator.push()returns aFuturethat will complete when the pushed route is popped- When
Navigator.pop(context, data)is called, it completes the Future with the provided data - The
awaitin the calling code receives this data when the navigation completes
Transition animations
Flutter's route transitions are implemented using animation controllers. When a route is pushed or popped:
- An
AnimationControlleris created with the specified duration - The controller drives a set of animations (like position, opacity, or scale)
- These animations update the visual representation of the route on each frame
For custom transitions using PageRouteBuilder:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => SecondScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Use animation to build a custom transition
return FadeTransition(opacity: animation, child: child);
},
),
);Here's what happens:
- The
pageBuildercreates the actual page widget - The
transitionsBuilderuses the provided animations to create a transition effect - The primary
animationgoes from 0.0 to 1.0 during a push, and from 1.0 to 0.0 during a pop - The
secondaryAnimationrepresents the animation of the route underneath
Nested navigation
Flutter also supports nested navigation, where you can have multiple navigators in the widget tree. This is useful for implementing bottom navigation bars where each tab has its own navigation stack.
In this case:
- Each tab has its own
Navigatorwidget with its own navigation stack - When switching tabs, you're actually switching between different Navigator widgets
- Each Navigator maintains its state independently
This creates a more complex but powerful navigation structure that can handle sophisticated app flows while maintaining good performance.
Deep links vs dynamic links
Both open your app on a specific screen from outside the app. A deep link stops at the store if the app is not installed. A dynamic link survives the install and takes the user to the right screen afterwards.
Deep links
Deep linking refers to the ability to use a URL or URI to navigate users directly to a specific screen or content within a mobile application, rather than just opening the app's home screen.
Characteristics
- Direct Navigation: Takes users directly to specific content within an app
- URL/URI Based: Uses a URL or URI scheme to define the destination
- App Installation Requirement: Traditional deep links work only if the app is already installed
Types of deep link
- Custom URL Schemes: Using a custom protocol (e.g.,
myapp://products/123) - Universal Links (iOS): Standard HTTP/HTTPS URLs that can open specific content in apps
- App Links (Android): Android's version of Universal Links
Implementation in Flutter
// Handle incoming links
void initDeepLinks() {
// For already running app
uriLinkStream.listen((Uri? uri) {
if (uri != null) {
handleDeepLink(uri);
}
});
// For app start
getInitialUri().then((Uri? uri) {
if (uri != null) {
handleDeepLink(uri);
}
});
}
void handleDeepLink(Uri uri) {
// Parse the URI and navigate accordingly
if (uri.pathSegments.contains('products')) {
final productId = uri.pathSegments.last;
Navigator.pushNamed(context, '/product', arguments: productId);
}
}Dynamic links
Dynamic linking is deep linking plus deferred behaviour: the link keeps working when the app is not installed yet, sending the user to the store and then to the right screen after the install.
Firebase Dynamic Links was the usual way to do this and shut down on 25 August 2025. Existing
links now return 404 and .page.link domains are gone. The code below is kept because the concepts
carry over, but for new work use App Links on Android and Universal Links on iOS for the deep link
half, and an attribution provider such as Branch, AppsFlyer, or Adjust when you need the deferred
half.
Characteristics
- Cross-Platform Support: Works across iOS and Android
- Persistence: Links work even if the app isn't installed yet
- App Store Redirection: Can redirect users to app stores if the app isn't installed
- Contextual Information: Can carry additional parameters and analytics data
- Deferred Deep Linking: Can navigate to the intended content after app installation
Key features
- Smart Link Redirection: Automatically directs users to the proper destination based on platform and app installation status
- Conversion Tracking: Provides analytics on link usage and conversions
- UTM Parameters Support: Enables marketing campaign tracking
- Shortened URLs: Creates more user-friendly links
Implementation with Firebase Dynamic Links (retired)
import 'package:firebase_dynamic_links/firebase_dynamic_links.dart';
Future<void> initDynamicLinks() async {
// Handle links when app is started by a link
final PendingDynamicLinkData? initialLink = await FirebaseDynamicLinks.instance.getInitialLink();
if (initialLink != null) {
handleDynamicLink(initialLink);
}
// Handle links when app is already running
FirebaseDynamicLinks.instance.onLink.listen(
(dynamicLinkData) {
handleDynamicLink(dynamicLinkData);
},
onError: (e) => print('Dynamic Link error: ${e.message}')
);
}
void handleDynamicLink(PendingDynamicLinkData data) {
final Uri deepLink = data.link;
// Example link: https://example.com/products/123?source=marketing
if (deepLink.pathSegments.contains('products')) {
final productId = deepLink.pathSegments.last;
final source = deepLink.queryParameters['source'];
// Navigate and track source
Navigator.pushNamed(
context,
'/product',
arguments: {'id': productId, 'source': source}
);
}
}
// Creating a dynamic link
Future<Uri> createDynamicLink(String productId) async {
final DynamicLinkParameters parameters = DynamicLinkParameters(
uriPrefix: 'https://yourapp.page.link',
link: Uri.parse('https://yourapp.com/products/$productId'),
androidParameters: AndroidParameters(
packageName: 'com.yourcompany.yourapp',
minimumVersion: 1,
),
iosParameters: IosParameters(
bundleId: 'com.yourcompany.yourapp',
minimumVersion: '1.0.0',
appStoreId: '123456789',
),
socialMetaTagParameters: SocialMetaTagParameters(
title: 'View Product',
description: 'Check out this amazing product!',
imageUrl: Uri.parse('https://yourapp.com/images/product.jpg'),
),
);
final shortDynamicLink = await FirebaseDynamicLinks.instance.buildShortLink(parameters);
return shortDynamicLink.shortUrl;
}Side by side
| Feature | Deep Linking | Dynamic Linking |
|---|---|---|
| App Installation Requirement | Requires app to be installed | Works with or without app installation |
| Cross-Platform Support | Requires separate implementation | Single solution for multiple platforms |
| Store Redirection | No built-in redirection | Automatic app store redirection |
| Analytics | No built-in analytics | Integrated analytics and attribution |
| Link Lifespan | Permanent | Can be configured (short-lived or permanent) |
| Implementation Complexity | Lower | Higher |
| Backend Requirements | Minimal | Requires service like Firebase |
When each is used
Dynamic linking could be particularly valuable for:
- User Onboarding: Sending personalized links that direct new users to a specific onboarding flow
- Referral Programs: Creating shareable links that track who referred a new user
- Transaction Sharing: Letting users share transaction details with others
- Marketing Campaigns: Tracking which campaigns drive the most app installations and conversions
- Customer Support: Directing users to specific sections based on support queries