Brain
EngineeringFlutter

Navigation and Linking

The Navigator and the route stack, named routes, passing data, transitions, and deep versus dynamic links.

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.

At the core of Flutter's navigation system are two key elements:

  1. Navigator: A widget that manages a stack of Route objects.
  2. 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

  1. 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)
  2. 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

Here's what happens internally when Navigator.push() is called:

  1. The Navigator widget receives the push request
  2. It calls createRoute() on the provided Route object to create the actual route
  3. It adds this route to its internal routes list
  4. It updates its state, which triggers a rebuild
  5. 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:

  1. Creates a new MaterialPageRoute with a builder function
  2. When the route is pushed, the builder function is called to create the actual widget
  3. 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:

  1. Looks up the route name in the predefined routes map
  2. Creates a MaterialPageRoute with the builder function from the map
  3. 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 requested
  • arguments: 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:

  1. When the route is created, it captures the reference to your data object
  2. 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:

  1. The arguments are stored in the RouteSettings object
  2. ModalRoute.of(context) gets the current route for the given context
  3. 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:

  1. Navigator.push() returns a Future that will complete when the pushed route is popped
  2. When Navigator.pop(context, data) is called, it completes the Future with the provided data
  3. The await in 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:

  1. An AnimationController is created with the specified duration
  2. The controller drives a set of animations (like position, opacity, or scale)
  3. 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:

  1. The pageBuilder creates the actual page widget
  2. The transitionsBuilder uses the provided animations to create a transition effect
  3. The primary animation goes from 0.0 to 1.0 during a push, and from 1.0 to 0.0 during a pop
  4. The secondaryAnimation represents 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:

  1. Each tab has its own Navigator widget with its own navigation stack
  2. When switching tabs, you're actually switching between different Navigator widgets
  3. 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.


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 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

  1. Direct Navigation: Takes users directly to specific content within an app
  2. URL/URI Based: Uses a URL or URI scheme to define the destination
  3. App Installation Requirement: Traditional deep links work only if the app is already installed
  1. Custom URL Schemes: Using a custom protocol (e.g., myapp://products/123)
  2. Universal Links (iOS): Standard HTTP/HTTPS URLs that can open specific content in apps
  3. 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 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

  1. Cross-Platform Support: Works across iOS and Android
  2. Persistence: Links work even if the app isn't installed yet
  3. App Store Redirection: Can redirect users to app stores if the app isn't installed
  4. Contextual Information: Can carry additional parameters and analytics data
  5. Deferred Deep Linking: Can navigate to the intended content after app installation

Key features

  1. Smart Link Redirection: Automatically directs users to the proper destination based on platform and app installation status
  2. Conversion Tracking: Provides analytics on link usage and conversions
  3. UTM Parameters Support: Enables marketing campaign tracking
  4. Shortened URLs: Creates more user-friendly links
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

FeatureDeep LinkingDynamic Linking
App Installation RequirementRequires app to be installedWorks with or without app installation
Cross-Platform SupportRequires separate implementationSingle solution for multiple platforms
Store RedirectionNo built-in redirectionAutomatic app store redirection
AnalyticsNo built-in analyticsIntegrated analytics and attribution
Link LifespanPermanentCan be configured (short-lived or permanent)
Implementation ComplexityLowerHigher
Backend RequirementsMinimalRequires service like Firebase

When each is used

Dynamic linking could be particularly valuable for:

  1. User Onboarding: Sending personalized links that direct new users to a specific onboarding flow
  2. Referral Programs: Creating shareable links that track who referred a new user
  3. Transaction Sharing: Letting users share transaction details with others
  4. Marketing Campaigns: Tracking which campaigns drive the most app installations and conversions
  5. Customer Support: Directing users to specific sections based on support queries

On this page