Introduction

Navigation is the skeleton of your app. Get it right, and users flow effortlessly through your product. Get it wrong, and they get lost, frustrated, and eventually leave.

Cross-platform frameworks add complexity: you need navigation that feels native on both iOS and Android while sharing as much code as possible. React Native Navigation and Flutter’s Navigator have matured significantly, but choosing the right patterns for your app still requires careful thought.

This guide covers navigation patterns that work across platforms, from basic stack navigation to complex authentication flows and deep linking.

The Navigation Stack Model

┌─────────────────────────────────────────────────────┐
│                    Root Navigator                    │
│  ┌───────────────────────────────────────────────┐  │
│  │              Auth Navigator                    │  │
│  │  ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │  │
│  │  │ Sign In │→│ Sign Up │→│ Forgot Password │ │  │

│  │  └─────────┘ └─────────┘ └─────────────────┘ │  │
│  └───────────────────────────────────────────────┘  │
│                          ↓                           │
│  ┌───────────────────────────────────────────────┐  │
│  │              Main Navigator (Tabs)             │  │
│  │  ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │  │
│  │  │  Home   │ │ Search  │ │    Profile      │ │  │
│  │  │  Stack  │ │  Stack  │ │     Stack       │ │  │
│  │  └─────────┘ └─────────┘ └─────────────────┘ │  │
│  └───────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘

React Native Navigation with React Navigation

React Native Navigation with React Navigation Infographic

Installation and Setup

// Install dependencies
// npm install @react-navigation/native @react-navigation/native-stack
// npm install @react-navigation/bottom-tabs
// npm install react-native-screens react-native-safe-area-context

// navigation/types.ts
export type RootStackParamList = {
  Auth: undefined;
  Main: undefined;
  ProductDetail: { productId: string };
  Checkout: { cartId: string };
};

export type AuthStackParamList = {
  SignIn: undefined;
  SignUp: undefined;
  ForgotPassword: { email?: string };
};

export type MainTabParamList = {
  Home: undefined;
  Search: undefined;
  Cart: undefined;
  Profile: undefined;
};

export type HomeStackParamList = {
  HomeScreen: undefined;
  Category: { categoryId: string; title: string };
  ProductList: { query: string };
};

// Typed navigation hooks
declare global {
  namespace ReactNavigation {
    interface RootParamList extends RootStackParamList {}
  }
}

Root Navigator

// navigation/RootNavigator.tsx
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useAuth } from '../hooks/useAuth';

const Stack = createNativeStackNavigator<RootStackParamList>();

export function RootNavigator() {
  const { isAuthenticated, isLoading } = useAuth();
  const linking = useLinking();

  if (isLoading) {
    return <SplashScreen />;
  }

  return (
    <NavigationContainer
      linking={linking}
      fallback={<LoadingScreen />}
      onStateChange={(state) => {
        // Analytics tracking
        const currentRoute = getActiveRoute(state);
        analytics.trackScreen(currentRoute.name, currentRoute.params);
      }}
    >
      <Stack.Navigator screenOptions={{ headerShown: false }}>
        {isAuthenticated ? (
          <>
            <Stack.Screen name="Main" component={MainNavigator} />
            <Stack.Screen
              name="ProductDetail"
              component={ProductDetailScreen}
              options={{
                headerShown: true,
                headerTransparent: true,
                animation: 'slide_from_right',
              }}
            />
            <Stack.Screen
              name="Checkout"
              component={CheckoutScreen}
              options={{
                presentation: 'modal',
                gestureEnabled: false,
              }}
            />
          </>
        ) : (
          <Stack.Screen name="Auth" component={AuthNavigator} />
        )}
      </Stack.Navigator>
    </NavigationContainer>
  );
}

// Helper to get active route for analytics
function getActiveRoute(state: NavigationState): Route {
  const route = state.routes[state.index];
  if (route.state) {
    return getActiveRoute(route.state as NavigationState);
  }
  return route;
}

Tab Navigator

// navigation/MainNavigator.tsx
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

const Tab = createBottomTabNavigator<MainTabParamList>();

export function MainNavigator() {
  const insets = useSafeAreaInsets();
  const cartItemCount = useCartItemCount();

  return (
    <Tab.Navigator
      screenOptions={({ route }) => ({
        headerShown: false,
        tabBarIcon: ({ focused, color, size }) => {
          const icons: Record<keyof MainTabParamList, string> = {
            Home: 'home',
            Search: 'search',
            Cart: 'shopping-cart',
            Profile: 'user',
          };
          return (
            <Icon
              name={icons[route.name]}
              size={size}
              color={color}
              filled={focused}
            />
          );
        },
        tabBarActiveTintColor: '#007AFF',
        tabBarInactiveTintColor: '#8E8E93',
        tabBarStyle: {
          paddingBottom: insets.bottom,
          height: 49 + insets.bottom,
        },
      })}
    >
      <Tab.Screen name="Home" component={HomeStack} />
      <Tab.Screen name="Search" component={SearchStack} />
      <Tab.Screen
        name="Cart"
        component={CartStack}
        options={{
          tabBarBadge: cartItemCount > 0 ? cartItemCount : undefined,
        }}
      />
      <Tab.Screen name="Profile" component={ProfileStack} />
    </Tab.Navigator>
  );
}

Deep Linking Configuration

// navigation/linking.ts
import { LinkingOptions } from '@react-navigation/native';
import { Linking } from 'react-native';

export function useLinking(): LinkingOptions<RootStackParamList> {
  return {
    prefixes: [
      'myapp://',
      'https://myapp.com',
      'https://www.myapp.com',
    ],
    config: {
      screens: {
        Auth: {
          screens: {
            SignIn: 'login',
            SignUp: 'signup',
            ForgotPassword: 'reset-password',
          },
        },
        Main: {
          screens: {
            Home: {
              screens: {
                HomeScreen: '',
                Category: 'category/:categoryId',
                ProductList: 'products',
              },
            },
            Search: 'search',
            Cart: 'cart',
            Profile: {
              screens: {
                ProfileScreen: 'profile',
                Settings: 'settings',
                Orders: 'orders',
              },
            },
          },
        },
        ProductDetail: {
          path: 'product/:productId',
          parse: {
            productId: (productId: string) => productId,
          },
        },
        Checkout: 'checkout/:cartId',
      },
    },
    async getInitialURL() {
      // Handle deep link that opened the app
      const url = await Linking.getInitialURL();
      if (url != null) {
        return url;
      }

      // Handle push notification deep link
      const notification = await getInitialNotification();
      if (notification?.data?.deepLink) {
        return notification.data.deepLink;
      }

      return null;
    },
    subscribe(listener) {
      // Handle links while app is running
      const linkingSubscription = Linking.addEventListener('url', ({ url }) => {
        listener(url);
      });

      // Handle push notification links
      const notificationSubscription = onNotificationOpenedApp((notification) => {
        if (notification.data?.deepLink) {
          listener(notification.data.deepLink);
        }
      });

      return () => {
        linkingSubscription.remove();
        notificationSubscription();
      };
    },
  };
}

Flutter Navigation

Flutter Navigation with GoRouter Infographic with GoRouter

Setup and Configuration

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

part 'router.g.dart';

@riverpod
GoRouter router(RouterRef ref) {
  final authState = ref.watch(authProvider);

  return GoRouter(
    initialLocation: '/',
    debugLogDiagnostics: true,
    refreshListenable: authState,
    redirect: (context, state) {
      final isAuthenticated = authState.isAuthenticated;
      final isAuthRoute = state.matchedLocation.startsWith('/auth');

      if (!isAuthenticated && !isAuthRoute) {
        // Redirect to login, preserving intended destination
        return '/auth/login?redirect=${state.matchedLocation}';
      }

      if (isAuthenticated && isAuthRoute) {
        // Already logged in, go to intended destination or home
        final redirect = state.uri.queryParameters['redirect'];
        return redirect ?? '/';
      }

      return null; // No redirect needed
    },
    routes: [
      // Auth routes
      GoRoute(
        path: '/auth',
        redirect: (_, __) => '/auth/login',
        routes: [
          GoRoute(
            path: 'login',
            builder: (context, state) => const SignInScreen(),
          ),
          GoRoute(
            path: 'signup',
            builder: (context, state) => const SignUpScreen(),
          ),
          GoRoute(
            path: 'forgot-password',
            builder: (context, state) {
              final email = state.uri.queryParameters['email'];
              return ForgotPasswordScreen(initialEmail: email);
            },
          ),
        ],
      ),

      // Main app with shell (tabs)
      ShellRoute(
        builder: (context, state, child) => MainShell(child: child),
        routes: [
          GoRoute(
            path: '/',
            pageBuilder: (context, state) => const NoTransitionPage(
              child: HomeScreen(),
            ),
            routes: [
              GoRoute(
                path: 'category/:categoryId',
                builder: (context, state) {
                  final categoryId = state.pathParameters['categoryId']!;
                  final title = state.uri.queryParameters['title'] ?? 'Category';
                  return CategoryScreen(
                    categoryId: categoryId,
                    title: title,
                  );
                },
              ),
            ],
          ),
          GoRoute(
            path: '/search',
            pageBuilder: (context, state) => const NoTransitionPage(
              child: SearchScreen(),
            ),
          ),
          GoRoute(
            path: '/cart',
            pageBuilder: (context, state) => const NoTransitionPage(
              child: CartScreen(),
            ),
          ),
          GoRoute(
            path: '/profile',
            pageBuilder: (context, state) => const NoTransitionPage(
              child: ProfileScreen(),
            ),
            routes: [
              GoRoute(
                path: 'settings',
                builder: (context, state) => const SettingsScreen(),
              ),
              GoRoute(
                path: 'orders',
                builder: (context, state) => const OrdersScreen(),
              ),
            ],
          ),
        ],
      ),

      // Modal routes (outside shell)
      GoRoute(
        path: '/product/:productId',
        builder: (context, state) {
          final productId = state.pathParameters['productId']!;
          return ProductDetailScreen(productId: productId);
        },
      ),
      GoRoute(
        path: '/checkout/:cartId',
        pageBuilder: (context, state) {
          final cartId = state.pathParameters['cartId']!;
          return MaterialPage(
            fullscreenDialog: true,
            child: CheckoutScreen(cartId: cartId),
          );
        },
      ),
    ],
    errorBuilder: (context, state) => ErrorScreen(error: state.error),
  );
}

Shell with Bottom Navigation

// lib/navigation/main_shell.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class MainShell extends StatelessWidget {
  final Widget child;

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
      bottomNavigationBar: const MainBottomNav(),
    );
  }
}

class MainBottomNav extends StatelessWidget {
  const MainBottomNav({super.key});

  @override
  Widget build(BuildContext context) {
    final location = GoRouterState.of(context).matchedLocation;
    final cartItemCount = context.watch<CartProvider>().itemCount;

    return NavigationBar(
      selectedIndex: _calculateSelectedIndex(location),
      onDestinationSelected: (index) => _onItemTapped(context, index),
      destinations: [
        const NavigationDestination(
          icon: Icon(Icons.home_outlined),
          selectedIcon: Icon(Icons.home),
          label: 'Home',
        ),
        const NavigationDestination(
          icon: Icon(Icons.search_outlined),
          selectedIcon: Icon(Icons.search),
          label: 'Search',
        ),
        NavigationDestination(
          icon: Badge(
            isLabelVisible: cartItemCount > 0,
            label: Text('$cartItemCount'),
            child: const Icon(Icons.shopping_cart_outlined),
          ),
          selectedIcon: Badge(
            isLabelVisible: cartItemCount > 0,
            label: Text('$cartItemCount'),
            child: const Icon(Icons.shopping_cart),
          ),
          label: 'Cart',
        ),
        const NavigationDestination(
          icon: Icon(Icons.person_outline),
          selectedIcon: Icon(Icons.person),
          label: 'Profile',
        ),
      ],
    );
  }

  int _calculateSelectedIndex(String location) {
    if (location.startsWith('/search')) return 1;
    if (location.startsWith('/cart')) return 2;
    if (location.startsWith('/profile')) return 3;
    return 0; // Home
  }

  void _onItemTapped(BuildContext context, int index) {
    switch (index) {
      case 0:
        context.go('/');
        break;
      case 1:
        context.go('/search');
        break;
      case 2:
        context.go('/cart');
        break;
      case 3:
        context.go('/profile');
        break;
    }
  }
}

Deep Linking in Flutter

// lib/main.dart
import 'package:app_links/app_links.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Handle deep link that opened the app
  final appLinks = AppLinks();
  final initialUri = await appLinks.getInitialLink();

  runApp(
    ProviderScope(
      overrides: [
        initialDeepLinkProvider.overrideWithValue(initialUri),
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final router = ref.watch(routerProvider);

    return MaterialApp.router(
      routerConfig: router,
      title: 'My App',
      theme: AppTheme.light,
      darkTheme: AppTheme.dark,
    );
  }
}

// Deep link handler
@riverpod
class DeepLinkHandler extends _$DeepLinkHandler {
  late final AppLinks _appLinks;
  StreamSubscription<Uri>? _subscription;

  @override
  void build() {
    _appLinks = AppLinks();

    _subscription = _appLinks.uriLinkStream.listen((uri) {
      _handleDeepLink(uri);
    });

    ref.onDispose(() {
      _subscription?.cancel();
    });
  }

  void _handleDeepLink(Uri uri) {
    final router = ref.read(routerProvider);

    // Convert URI to app route
    final path = uri.path;
    final queryParams = uri.queryParameters;

    // Handle special cases
    if (path.startsWith('/share/')) {
      _handleShareLink(path, queryParams);
      return;
    }

    // Default: navigate to the path
    router.go(uri.toString());
  }

  void _handleShareLink(String path, Map<String, String> params) {
    // Custom handling for share links
    final productId = params['product'];
    if (productId != null) {
      ref.read(routerProvider).go('/product/$productId');
    }
  }
}

Authentication Flow Patterns

React Native Auth Flow

// hooks/useAuth.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface AuthState {
  user: User | null;
  token: string | null;
  isLoading: boolean;
  isAuthenticated: boolean;

  signIn: (email: string, password: string) => Promise<void>;
  signUp: (data: SignUpData) => Promise<void>;
  signOut: () => Promise<void>;
  refreshToken: () => Promise<void>;
}

export const useAuth = create<AuthState>()(
  persist(
    (set, get) => ({
      user: null,
      token: null,
      isLoading: true,
      isAuthenticated: false,

      signIn: async (email, password) => {
        set({ isLoading: true });
        try {
          const { user, token } = await authApi.signIn(email, password);
          set({ user, token, isAuthenticated: true, isLoading: false });
        } catch (error) {
          set({ isLoading: false });
          throw error;
        }
      },

      signUp: async (data) => {
        set({ isLoading: true });
        try {
          const { user, token } = await authApi.signUp(data);
          set({ user, token, isAuthenticated: true, isLoading: false });
        } catch (error) {
          set({ isLoading: false });
          throw error;
        }
      },

      signOut: async () => {
        try {
          await authApi.signOut();
        } finally {
          set({ user: null, token: null, isAuthenticated: false });
        }
      },

      refreshToken: async () => {
        const currentToken = get().token;
        if (!currentToken) return;

        try {
          const { token } = await authApi.refreshToken(currentToken);
          set({ token });
        } catch {
          // Token refresh failed, sign out
          get().signOut();
        }
      },
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => AsyncStorage),
      partialize: (state) => ({
        user: state.user,
        token: state.token,
        isAuthenticated: state.isAuthenticated,
      }),
      onRehydrateStorage: () => (state) => {
        state?.set({ isLoading: false });
      },
    }
  )
);

// Protected route wrapper
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const navigation = useNavigation();
  const { isAuthenticated, isLoading } = useAuth();

  useEffect(() => {
    if (!isLoading && !isAuthenticated) {
      navigation.reset({
        index: 0,
        routes: [{ name: 'Auth' }],
      });
    }
  }, [isAuthenticated, isLoading, navigation]);

  if (isLoading) {
    return <LoadingScreen />;
  }

  if (!isAuthenticated) {
    return null;
  }

  return <>{children}</>;
}

Flutter Auth Flow

// lib/providers/auth_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'auth_provider.g.dart';

@riverpod
class Auth extends _$Auth {
  @override
  Future<AuthState> build() async {
    // Load stored auth state
    final storage = ref.watch(secureStorageProvider);
    final token = await storage.read(key: 'auth_token');
    final userJson = await storage.read(key: 'user');

    if (token != null && userJson != null) {
      try {
        // Validate token
        final user = await ref.read(authApiProvider).validateToken(token);
        return AuthState.authenticated(user: user, token: token);
      } catch (e) {
        // Token invalid, clear storage
        await storage.deleteAll();
      }
    }

    return const AuthState.unauthenticated();
  }

  Future<void> signIn(String email, String password) async {
    state = const AsyncValue.loading();

    state = await AsyncValue.guard(() async {
      final response = await ref.read(authApiProvider).signIn(email, password);

      // Store credentials
      final storage = ref.read(secureStorageProvider);
      await storage.write(key: 'auth_token', value: response.token);
      await storage.write(key: 'user', value: jsonEncode(response.user.toJson()));

      return AuthState.authenticated(
        user: response.user,
        token: response.token,
      );
    });
  }

  Future<void> signOut() async {
    try {
      await ref.read(authApiProvider).signOut();
    } finally {
      // Clear storage
      final storage = ref.read(secureStorageProvider);
      await storage.deleteAll();

      state = const AsyncValue.data(AuthState.unauthenticated());
    }
  }
}

@freezed
class AuthState with _$AuthState {
  const factory AuthState.unauthenticated() = _Unauthenticated;
  const factory AuthState.authenticated({
    required User user,
    required String token,
  }) = _Authenticated;

  const AuthState._();

  bool get isAuthenticated => this is _Authenticated;

  User? get user => mapOrNull(
        authenticated: (state) => state.user,
      );
}

Multi-Step Form Navigation

// React Native: Checkout flow with step tracking
type CheckoutStep = 'address' | 'shipping' | 'payment' | 'review';

export function CheckoutNavigator() {
  const [currentStep, setCurrentStep] = useState<CheckoutStep>('address');

  return (
    <View style={styles.container}>
      <StepIndicator
        steps={['Address', 'Shipping', 'Payment', 'Review']}
        currentStep={stepIndex(currentStep)}
      />

      <Stack.Navigator
        screenOptions={{
          headerShown: false,
          animation: 'slide_from_right',
          gestureEnabled: false, // Prevent swipe back during checkout
        }}
      >
        <Stack.Screen name="Address">
          {() => (
            <AddressStep
              onNext={() => {
                setCurrentStep('shipping');
                navigation.navigate('Shipping');
              }}
            />
          )}
        </Stack.Screen>

        <Stack.Screen name="Shipping">
          {() => (
            <ShippingStep
              onBack={() => {
                setCurrentStep('address');
                navigation.goBack();
              }}
              onNext={() => {
                setCurrentStep('payment');
                navigation.navigate('Payment');
              }}
            />
          )}
        </Stack.Screen>

        <Stack.Screen name="Payment">
          {() => (
            <PaymentStep
              onBack={() => {
                setCurrentStep('shipping');
                navigation.goBack();
              }}
              onNext={() => {
                setCurrentStep('review');
                navigation.navigate('Review');
              }}
            />
          )}
        </Stack.Screen>

        <Stack.Screen name="Review">
          {() => (
            <ReviewStep
              onBack={() => {
                setCurrentStep('payment');
                navigation.goBack();
              }}
              onComplete={handleOrderComplete}
            />
          )}
        </Stack.Screen>
      </Stack.Navigator>
    </View>
  );
}
// Flutter: Nested modal navigation
class ProductDetailScreen extends StatelessWidget {
  final String productId;

  const ProductDetailScreen({required this.productId, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          SliverAppBar(
            expandedHeight: 300,
            pinned: true,
            flexibleSpace: FlexibleSpaceBar(
              background: ProductImageGallery(productId: productId),
            ),
          ),
          SliverToBoxAdapter(
            child: ProductInfo(productId: productId),
          ),
        ],
      ),
      bottomNavigationBar: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            children: [
              Expanded(
                child: OutlinedButton(
                  onPressed: () => _showSizeSelector(context),
                  child: const Text('Select Size'),
                ),
              ),
              const SizedBox(width: 16),
              Expanded(
                child: FilledButton(
                  onPressed: () => _addToCart(context),
                  child: const Text('Add to Cart'),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  void _showSizeSelector(BuildContext context) {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      builder: (context) => DraggableScrollableSheet(
        initialChildSize: 0.5,
        minChildSize: 0.25,
        maxChildSize: 0.9,
        expand: false,
        builder: (context, scrollController) => SizeSelector(
          productId: productId,
          scrollController: scrollController,
          onSelect: (size) {
            Navigator.pop(context);
            // Size selected
          },
        ),
      ),
    );
  }

  void _addToCart(BuildContext context) {
    // Add to cart logic
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: const Text('Added to cart'),
        action: SnackBarAction(
          label: 'View Cart',
          onPressed: () => context.go('/cart'),
        ),
      ),
    );
  }
}

Platform-Specific Adaptations

Adaptive Navigation

// React Native: Platform-specific navigation
import { Platform } from 'react-native';
import { createDrawerNavigator } from '@react-navigation/drawer';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';

const Drawer = createDrawerNavigator();
const Tab = createBottomTabNavigator();

export function MainNavigator() {
  // Use drawer on tablets, tabs on phones
  const isTablet = useIsTablet();

  if (Platform.OS === 'ios') {
    return <TabNavigator />;
  }

  if (Platform.OS === 'android' && isTablet) {
    return <DrawerNavigator />;
  }

  return <TabNavigator />;
}

function DrawerNavigator() {
  return (
    <Drawer.Navigator
      screenOptions={{
        drawerType: 'permanent',
        drawerStyle: { width: 280 },
      }}
    >
      <Drawer.Screen name="Home" component={HomeStack} />
      <Drawer.Screen name="Search" component={SearchStack} />
      <Drawer.Screen name="Cart" component={CartStack} />
      <Drawer.Screen name="Profile" component={ProfileStack} />
    </Drawer.Navigator>
  );
}
// Flutter: Adaptive layout
class AdaptiveNavigationShell extends StatelessWidget {
  final Widget child;

  const AdaptiveNavigationShell({required this.child, super.key});

  @override
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.sizeOf(context).width;
    final isWide = screenWidth > 600;

    if (isWide) {
      return Row(
        children: [
          const NavigationRail(
            destinations: [
              NavigationRailDestination(
                icon: Icon(Icons.home_outlined),
                selectedIcon: Icon(Icons.home),
                label: Text('Home'),
              ),
              // ... other destinations
            ],
          ),
          const VerticalDivider(thickness: 1, width: 1),
          Expanded(child: child),
        ],
      );
    }

    return Scaffold(
      body: child,
      bottomNavigationBar: const MainBottomNav(),
    );
  }
}

Testing Navigation

React Native Navigation Tests

// __tests__/navigation.test.tsx
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';

describe('Navigation', () => {
  it('navigates to product detail when product is tapped', async () => {
    const { getByTestId, getByText } = render(
      <NavigationContainer>
        <RootNavigator />
      </NavigationContainer>
    );

    // Find and tap a product
    const product = await waitFor(() => getByTestId('product-1'));
    fireEvent.press(product);

    // Should be on product detail screen
    await waitFor(() => {
      expect(getByText('Product Details')).toBeTruthy();
    });
  });

  it('redirects to login when accessing protected route', async () => {
    // Clear auth state
    useAuth.getState().signOut();

    const { getByText } = render(
      <NavigationContainer
        initialState={{
          routes: [{ name: 'Main' }],
        }}
      >
        <RootNavigator />
      </NavigationContainer>
    );

    // Should redirect to auth
    await waitFor(() => {
      expect(getByText('Sign In')).toBeTruthy();
    });
  });

  it('handles deep link correctly', async () => {
    const { getByText } = render(
      <NavigationContainer
        linking={{
          ...linking,
          getInitialURL: () => 'myapp://product/123',
        }}
      >
        <RootNavigator />
      </NavigationContainer>
    );

    await waitFor(() => {
      expect(getByText('Product 123')).toBeTruthy();
    });
  });
});

Conclusion

Navigation is foundational to mobile app architecture. The patterns covered here provide a solid starting point:

  1. Type-safe navigation prevents runtime errors and improves developer experience
  2. Nested navigators organize complex app structures
  3. Deep linking enables marketing, sharing, and notification flows
  4. Auth-aware routing protects sensitive screens
  5. Platform adaptations respect user expectations on each platform

Start simple with stack navigation. Add tabs as your app grows. Implement deep linking early—retrofitting is painful. Test navigation flows thoroughly, as navigation bugs frustrate users more than most other issues.

Both React Navigation and GoRouter are mature, well-documented solutions. Choose based on your team’s familiarity and specific requirements. The patterns transfer between frameworks.


Building a cross-platform app with complex navigation requirements? We have shipped apps with intricate navigation flows serving millions of Australian users. Contact us to discuss your architecture.