DartFlutter

Best Practices for Flutter Routing and Deep Linking in 2025

Best Practices for Flutter Routing and Deep Linking in 2025

Routing is one of the most crucial parts of Flutter app development, especially when you’re building scalable, multi-screen apps. Combine that with deep linking, and you unlock seamless navigation from external sources like push notifications, email campaigns, QR codes, and browser URLs.

In this comprehensive guide, we’ll cover the best practices for Flutter routing and deep linking in 2025, including complete GoRouter configurations, shell routes for persistent navigation, authentication guards, and platform-specific deep link setup.

What is Routing in Flutter?

Routing refers to how you navigate between screens in your Flutter app. There are two main approaches:

  • Imperative routing – Direct navigation commands like Navigator.push
  • Declarative routing – URL-based navigation with packages like GoRouter or AutoRoute

Flutter officially recommends GoRouter for declarative, flexible, and deep-link-friendly navigation. It handles browser history on web, maintains state across rebuilds, and provides powerful redirect capabilities.

What is Deep Linking?

Deep linking allows your app to open specific screens via URLs:

  • Custom scheme: myapp://product/123
  • Universal links: https://yourapp.com/product/123
  • App links (Android): https://yourapp.com/order/abc

Deep links are essential for marketing campaigns, push notifications, email CTAs, QR codes, and web-to-app transitions.

Complete GoRouter Setup

Let’s build a production-ready routing configuration with authentication, shell routes, and type-safe navigation.

lib/router/app_router.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/auth_provider.dart';
import '../screens/screens.dart';

// Route names as constants to prevent typos
class AppRoutes {
  static const home = 'home';
  static const login = 'login';
  static const register = 'register';
  static const forgotPassword = 'forgot-password';
  static const products = 'products';
  static const productDetail = 'product-detail';
  static const cart = 'cart';
  static const checkout = 'checkout';
  static const orders = 'orders';
  static const orderDetail = 'order-detail';
  static const profile = 'profile';
  static const settings = 'settings';
  static const notFound = 'not-found';
}

// Type-safe route parameters
class ProductDetailParams {
  final String productId;
  final String? variant;

  ProductDetailParams({required this.productId, this.variant});

  Map<String, String> toPathParams() => {'productId': productId};
  Map<String, String> toQueryParams() =>
      variant != null ? {'variant': variant!} : {};
}

final routerProvider = Provider<GoRouter>((ref) {
  final authState = ref.watch(authProvider);

  return GoRouter(
    initialLocation: '/',
    debugLogDiagnostics: true,
    refreshListenable: GoRouterRefreshStream(ref.read(authProvider.notifier).stream),

    // Global redirect for authentication
    redirect: (context, state) {
      final isLoggedIn = authState.status == AuthStatus.authenticated;
      final isLoggingIn = state.matchedLocation == '/login' ||
          state.matchedLocation == '/register' ||
          state.matchedLocation == '/forgot-password';

      // If not logged in and not on auth page, redirect to login
      if (!isLoggedIn && !isLoggingIn) {
        // Preserve the intended destination for after login
        return '/login?redirect=${Uri.encodeComponent(state.matchedLocation)}';
      }

      // If logged in and on auth page, redirect to home
      if (isLoggedIn && isLoggingIn) {
        final redirect = state.uri.queryParameters['redirect'];
        return redirect != null ? Uri.decodeComponent(redirect) : '/';
      }

      return null; // No redirect needed
    },

    // 404 handler
    errorBuilder: (context, state) => NotFoundScreen(
      path: state.matchedLocation,
    ),

    routes: [
      // Auth routes (no shell)
      GoRoute(
        path: '/login',
        name: AppRoutes.login,
        builder: (context, state) => const LoginScreen(),
      ),
      GoRoute(
        path: '/register',
        name: AppRoutes.register,
        builder: (context, state) => const RegisterScreen(),
      ),
      GoRoute(
        path: '/forgot-password',
        name: AppRoutes.forgotPassword,
        builder: (context, state) => const ForgotPasswordScreen(),
      ),

      // Main app with bottom navigation shell
      StatefulShellRoute.indexedStack(
        builder: (context, state, navigationShell) => MainShell(
          navigationShell: navigationShell,
        ),
        branches: [
          // Home branch
          StatefulShellBranch(
            routes: [
              GoRoute(
                path: '/',
                name: AppRoutes.home,
                builder: (context, state) => const HomeScreen(),
                routes: [
                  GoRoute(
                    path: 'products',
                    name: AppRoutes.products,
                    builder: (context, state) {
                      final category = state.uri.queryParameters['category'];
                      final search = state.uri.queryParameters['q'];
                      return ProductsScreen(
                        category: category,
                        searchQuery: search,
                      );
                    },
                    routes: [
                      GoRoute(
                        path: ':productId',
                        name: AppRoutes.productDetail,
                        builder: (context, state) {
                          final productId = state.pathParameters['productId']!;
                          final variant = state.uri.queryParameters['variant'];
                          return ProductDetailScreen(
                            productId: productId,
                            selectedVariant: variant,
                          );
                        },
                      ),
                    ],
                  ),
                ],
              ),
            ],
          ),

          // Orders branch
          StatefulShellBranch(
            routes: [
              GoRoute(
                path: '/orders',
                name: AppRoutes.orders,
                builder: (context, state) => const OrdersScreen(),
                routes: [
                  GoRoute(
                    path: ':orderId',
                    name: AppRoutes.orderDetail,
                    builder: (context, state) {
                      final orderId = state.pathParameters['orderId']!;
                      return OrderDetailScreen(orderId: orderId);
                    },
                  ),
                ],
              ),
            ],
          ),

          // Cart branch
          StatefulShellBranch(
            routes: [
              GoRoute(
                path: '/cart',
                name: AppRoutes.cart,
                builder: (context, state) => const CartScreen(),
                routes: [
                  GoRoute(
                    path: 'checkout',
                    name: AppRoutes.checkout,
                    builder: (context, state) => const CheckoutScreen(),
                  ),
                ],
              ),
            ],
          ),

          // Profile branch
          StatefulShellBranch(
            routes: [
              GoRoute(
                path: '/profile',
                name: AppRoutes.profile,
                builder: (context, state) => const ProfileScreen(),
                routes: [
                  GoRoute(
                    path: 'settings',
                    name: AppRoutes.settings,
                    builder: (context, state) => const SettingsScreen(),
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
    ],
  );
});

// Helper class to refresh router when auth state changes
class GoRouterRefreshStream extends ChangeNotifier {
  GoRouterRefreshStream(Stream<dynamic> stream) {
    stream.listen((_) => notifyListeners());
  }
}

Main Shell with Bottom Navigation

The shell maintains state across tab switches and preserves navigation history within each branch.

lib/screens/main_shell.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class MainShell extends StatelessWidget {
  final StatefulNavigationShell navigationShell;

  const MainShell({required this.navigationShell, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: NavigationBar(
        selectedIndex: navigationShell.currentIndex,
        onDestinationSelected: (index) {
          // Navigate to branch, preserving state
          navigationShell.goBranch(
            index,
            // Go to initial location if tapping current tab
            initialLocation: index == navigationShell.currentIndex,
          );
        },
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.home_outlined),
            selectedIcon: Icon(Icons.home),
            label: 'Home',
          ),
          NavigationDestination(
            icon: Icon(Icons.receipt_long_outlined),
            selectedIcon: Icon(Icons.receipt_long),
            label: 'Orders',
          ),
          NavigationDestination(
            icon: Icon(Icons.shopping_cart_outlined),
            selectedIcon: Icon(Icons.shopping_cart),
            label: 'Cart',
          ),
          NavigationDestination(
            icon: Icon(Icons.person_outline),
            selectedIcon: Icon(Icons.person),
            label: 'Profile',
          ),
        ],
      ),
    );
  }
}

Type-Safe Navigation Extension

Create an extension for type-safe navigation throughout your app.

lib/router/navigation_extensions.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'app_router.dart';

extension NavigationExtensions on BuildContext {
  // Go to home
  void goHome() => goNamed(AppRoutes.home);

  // Go to product detail
  void goToProduct(String productId, {String? variant}) {
    goNamed(
      AppRoutes.productDetail,
      pathParameters: {'productId': productId},
      queryParameters: variant != null ? {'variant': variant} : {},
    );
  }

  // Go to products with optional filters
  void goToProducts({String? category, String? search}) {
    goNamed(
      AppRoutes.products,
      queryParameters: {
        if (category != null) 'category': category,
        if (search != null) 'q': search,
      },
    );
  }

  // Go to order detail
  void goToOrder(String orderId) {
    goNamed(
      AppRoutes.orderDetail,
      pathParameters: {'orderId': orderId},
    );
  }

  // Go to cart
  void goToCart() => goNamed(AppRoutes.cart);

  // Go to checkout (push, not replace)
  void pushCheckout() => pushNamed(AppRoutes.checkout);

  // Go to login with redirect back to current page
  void goToLogin({String? redirectAfter}) {
    goNamed(
      AppRoutes.login,
      queryParameters: redirectAfter != null ? {'redirect': redirectAfter} : {},
    );
  }
}

// Usage in screens:
// context.goToProduct('abc123', variant: 'blue');
// context.goToProducts(category: 'electronics');
// context.goToOrder('order-456');

Configure deep links for each platform to handle both custom schemes and universal links.

Android Configuration

android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
  <application ...>
    <activity
      android:name=".MainActivity"
      android:exported="true"
      android:launchMode="singleTop">

      <!-- Standard launcher intent -->
      <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
      </intent-filter>

      <!-- Custom URL scheme (myapp://...) -->
      <intent-filter>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data android:scheme="myapp"/>
      </intent-filter>

      <!-- App Links (https://yourapp.com/...) -->
      <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="yourapp.com"/>
      </intent-filter>

    </activity>
  </application>
</manifest>

assetlinks.json (host at https://yourapp.com/.well-known/assetlinks.json)

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.yourcompany.yourapp",
    "sha256_cert_fingerprints": [
      "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
    ]
  }
}]

iOS Configuration

ios/Runner/Info.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <!-- Custom URL scheme -->
  <key>CFBundleURLTypes</key>
  <array>
    <dict>
      <key>CFBundleTypeRole</key>
      <string>Editor</string>
      <key>CFBundleURLName</key>
      <string>com.yourcompany.yourapp</string>
      <key>CFBundleURLSchemes</key>
      <array>
        <string>myapp</string>
      </array>
    </dict>
  </array>

  <!-- Universal Links -->
  <key>FlutterDeepLinkingEnabled</key>
  <true/>

  <!-- ... other plist entries -->
</dict>
</plist>

apple-app-site-association (host at https://yourapp.com/.well-known/apple-app-site-association)

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.yourcompany.yourapp",
        "paths": [
          "/products/*",
          "/orders/*",
          "/cart",
          "/profile/*"
        ]
      }
    ]
  }
}

Process incoming deep links and extract parameters for navigation.

lib/services/deep_link_service.dart

import 'package:app_links/app_links.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../router/app_router.dart';

class DeepLinkService {
  final AppLinks _appLinks = AppLinks();
  final GoRouter _router;

  DeepLinkService(this._router) {
    _init();
  }

  Future<void> _init() async {
    // Handle link that opened the app (cold start)
    final initialLink = await _appLinks.getInitialLink();
    if (initialLink != null) {
      _handleDeepLink(initialLink);
    }

    // Handle links while app is running (warm start)
    _appLinks.uriLinkStream.listen(_handleDeepLink);
  }

  void _handleDeepLink(Uri uri) {
    // Convert external URL to internal path
    final path = _convertToInternalPath(uri);
    if (path != null) {
      _router.go(path);
    }
  }

  String? _convertToInternalPath(Uri uri) {
    // Handle custom scheme: myapp://product/123
    if (uri.scheme == 'myapp') {
      return '/${uri.host}${uri.path}';
    }

    // Handle universal links: https://yourapp.com/products/123
    if (uri.host == 'yourapp.com') {
      return uri.path;
    }

    // Handle Firebase Dynamic Links or other shortened URLs
    if (uri.queryParameters.containsKey('link')) {
      final innerLink = Uri.parse(uri.queryParameters['link']!);
      return _convertToInternalPath(innerLink);
    }

    return null;
  }
}

final deepLinkServiceProvider = Provider((ref) {
  final router = ref.watch(routerProvider);
  return DeepLinkService(router);
});

Deep Link Analytics Tracking

Track deep link opens for marketing attribution and user journey analysis.

// lib/services/analytics_service.dart
import 'package:firebase_analytics/firebase_analytics.dart';

class AnalyticsService {
  final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;

  Future<void> trackDeepLinkOpen({
    required Uri uri,
    required String source,
    String? campaign,
    String? medium,
  }) async {
    await _analytics.logEvent(
      name: 'deep_link_open',
      parameters: {
        'full_url': uri.toString(),
        'path': uri.path,
        'source': source,
        if (campaign != null) 'campaign': campaign,
        if (medium != null) 'medium': medium,
        'timestamp': DateTime.now().toIso8601String(),
      },
    );
  }

  Future<void> trackScreenView(String screenName, String screenClass) async {
    await _analytics.logScreenView(
      screenName: screenName,
      screenClass: screenClass,
    );
  }
}

// Usage in deep link handler:
// await analyticsService.trackDeepLinkOpen(
//   uri: uri,
//   source: uri.queryParameters['utm_source'] ?? 'direct',
//   campaign: uri.queryParameters['utm_campaign'],
//   medium: uri.queryParameters['utm_medium'],
// );

Custom Route Transitions

Add custom page transitions for a polished navigation experience.

// lib/router/page_transitions.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

CustomTransitionPage<void> buildFadeTransition({
  required BuildContext context,
  required GoRouterState state,
  required Widget child,
}) {
  return CustomTransitionPage<void>(
    key: state.pageKey,
    child: child,
    transitionDuration: const Duration(milliseconds: 200),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return FadeTransition(
        opacity: CurveTween(curve: Curves.easeInOut).animate(animation),
        child: child,
      );
    },
  );
}

CustomTransitionPage<void> buildSlideTransition({
  required BuildContext context,
  required GoRouterState state,
  required Widget child,
}) {
  return CustomTransitionPage<void>(
    key: state.pageKey,
    child: child,
    transitionDuration: const Duration(milliseconds: 300),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      final offsetAnimation = Tween<Offset>(
        begin: const Offset(1.0, 0.0),
        end: Offset.zero,
      ).animate(CurvedAnimation(
        parent: animation,
        curve: Curves.easeOutCubic,
      ));

      return SlideTransition(
        position: offsetAnimation,
        child: child,
      );
    },
  );
}

// Usage in route definition:
// GoRoute(
//   path: '/product/:id',
//   pageBuilder: (context, state) => buildSlideTransition(
//     context: context,
//     state: state,
//     child: ProductDetailScreen(productId: state.pathParameters['id']!),
//   ),
// )

Common Mistakes to Avoid

1. Using string-based navigation

// Wrong: Prone to typos and breaks when paths change
context.go('/porduct/123'); // Typo goes unnoticed until runtime

// Correct: Use named routes with constants
context.goNamed(AppRoutes.productDetail, pathParameters: {'productId': '123'});

2. Not preserving shell state

// Wrong: Loses scroll position and state when switching tabs
context.go('/orders');

// Correct: Use StatefulShellRoute with goBranch
navigationShell.goBranch(1); // Preserves state

3. Missing redirect after login

// Wrong: Always goes to home after login
if (isLoggedIn && isOnLoginPage) return '/';

// Correct: Return to intended destination
if (isLoggedIn && isOnLoginPage) {
  final redirect = state.uri.queryParameters['redirect'];
  return redirect != null ? Uri.decodeComponent(redirect) : '/';
}

4. Not validating path parameters

// Wrong: Crashes if parameter is missing
final productId = state.pathParameters['productId']!;

// Correct: Validate and handle missing parameters
final productId = state.pathParameters['productId'];
if (productId == null || productId.isEmpty) {
  return const NotFoundScreen();
}
return ProductDetailScreen(productId: productId);

5. Forgetting to handle app links verification

<!-- Wrong: Links won't work without verification -->
<intent-filter>
  <data android:scheme="https" android:host="yourapp.com"/>
</intent-filter>

<!-- Correct: Include autoVerify for App Links -->
<intent-filter android:autoVerify="true">
  <data android:scheme="https" android:host="yourapp.com"/>
</intent-filter>

Test your deep links during development and in production builds.

# Test on Android emulator
adb shell am start -a android.intent.action.VIEW \
  -d "myapp://products/123" com.yourcompany.yourapp

# Test universal links on Android
adb shell am start -a android.intent.action.VIEW \
  -d "https://yourapp.com/products/123" com.yourcompany.yourapp

# Test on iOS simulator
xcrun simctl openurl booted "myapp://products/123"

# Test universal links on iOS
xcrun simctl openurl booted "https://yourapp.com/products/123"

# Verify Android App Links setup
adb shell pm get-app-links com.yourcompany.yourapp

Tools and Plugins

Final Thoughts

Flutter routing and deep linking have evolved significantly. In 2025, the combination of GoRouter, StatefulShellRoute for persistent navigation, and proper platform configuration provides a polished and scalable navigation experience across all platforms.

The key to success is planning your route structure early, using type-safe navigation helpers, and thoroughly testing deep links on both platforms before release. Remember to set up universal links and app links verification for the best user experience when users tap links in emails, messages, or social media.

Leave a Comment